mirror of
https://github.com/fiatjaf/nak.git
synced 2025-12-08 16:48:51 +00:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35da063c30 | ||
|
|
15aefe3df4 | ||
|
|
55fd631787 | ||
|
|
6f48c29d0f | ||
|
|
703c186958 | ||
|
|
7ae2e686cb | ||
|
|
9547711e8d | ||
|
|
50119e21e6 | ||
|
|
33f4272dd0 | ||
|
|
7b6f387aad | ||
|
|
b1a03800e6 | ||
|
|
db5dafb58a | ||
|
|
4b15cdf625 | ||
|
|
4b8c067e00 | ||
|
|
931da4b0ae | ||
|
|
c87371208e | ||
|
|
bfe1e6ca94 | ||
|
|
602e03a9a1 | ||
|
|
fe1f50f798 | ||
|
|
d899a92f15 | ||
|
|
1c058f2846 | ||
|
|
4b4d9ec155 | ||
|
|
3031568266 | ||
|
|
a828ee3793 | ||
|
|
186948db9a | ||
|
|
5fe354f642 | ||
|
|
3d961d4bec | ||
|
|
d6a23bd00c | ||
|
|
c1248eb37b | ||
|
|
c60bb82be8 | ||
|
|
f5316a0f35 | ||
|
|
e6448debf2 | ||
|
|
7bb7543ef7 | ||
|
|
43a3e5f40d | ||
|
|
707e5b3918 | ||
|
|
faca2e50f0 | ||
|
|
26930d40bc | ||
|
|
17920d8aef | ||
|
|
95bed5d5a8 | ||
|
|
2e30dfe2eb | ||
|
|
55c6f75b8a | ||
|
|
1f2492c9b1 | ||
|
|
d00976a669 | ||
|
|
4392293ed6 | ||
|
|
60d1292f80 | ||
|
|
6c634d8081 | ||
|
|
1e353680bc | ||
|
|
ff8701a3b0 | ||
|
|
ad6b8c4ba5 | ||
|
|
dba3f648ad | ||
|
|
12a1f1563e | ||
|
|
e2dd3ca544 | ||
|
|
df5ebd3f56 | ||
|
|
81571c6952 | ||
|
|
6e43a6b733 | ||
|
|
943e8835f9 | ||
|
|
6b659c1552 | ||
|
|
aa53f2cd60 | ||
|
|
5509095277 | ||
|
|
a3ef9b45de | ||
|
|
df20a3241a | ||
|
|
53a2451303 | ||
|
|
2d992f235e | ||
|
|
7675929056 | ||
|
|
7f608588a2 | ||
|
|
fd5cd55f6f | ||
|
|
932361fe8f | ||
|
|
11ae7bc4d3 | ||
|
|
7033bfee19 | ||
|
|
f425097c5a | ||
|
|
dd0ef2ca64 | ||
|
|
491a094e07 | ||
|
|
9d619ddf00 | ||
|
|
5d32739573 | ||
|
|
a187e448f2 | ||
|
|
9a9e96a829 | ||
|
|
4c6181d649 | ||
|
|
71b106fd45 | ||
|
|
40892c1228 | ||
|
|
847f8aaa69 | ||
|
|
134d1225d6 | ||
|
|
464766a836 | ||
|
|
ea53eca74f | ||
|
|
38ed370c59 | ||
|
|
5b04bc4859 | ||
|
|
2988c71ccb | ||
|
|
d7c0ff2bb7 | ||
|
|
43fe41df5d | ||
|
|
3215726417 | ||
|
|
a4886dc445 | ||
|
|
dae7eba8ca | ||
|
|
2b5f3355bc | ||
|
|
bd5ca27661 | ||
|
|
9d02301b2d | ||
|
|
9bbc87b27a | ||
|
|
88a07a3504 | ||
|
|
8a934cc76b | ||
|
|
e0c967efa9 | ||
|
|
36c32ae308 | ||
|
|
6d23509d8c | ||
|
|
29b6ecbafe | ||
|
|
11f37afa5b | ||
|
|
cf1694704e | ||
|
|
b3ef2c1289 | ||
|
|
cfdea699bc | ||
|
|
014c6bc11d | ||
|
|
0240866fa1 | ||
|
|
a4d9ceecfa | ||
|
|
56657d8aa9 | ||
|
|
ea7b88cfd7 | ||
|
|
2042b14578 | ||
|
|
9d43e66fac | ||
|
|
85e9610265 | ||
|
|
2edfa5cbea | ||
|
|
9690dc70cb | ||
|
|
c90e61dbec | ||
|
|
d226cd6ce4 | ||
|
|
3d78e91f62 | ||
|
|
84965f2253 | ||
|
|
928c73513c | ||
|
|
a36142604d | ||
|
|
220fe84f1b | ||
|
|
48c0e342e3 | ||
|
|
ec2e214c02 | ||
|
|
9f62d4679f | ||
|
|
809865ca0c | ||
|
|
813ab3b6ac | ||
|
|
09ed2a040a | ||
|
|
7846960c4e | ||
|
|
ce6bb0aa22 | ||
|
|
49ce12ffc2 | ||
|
|
a5013c513d | ||
|
|
8f51fe757b | ||
|
|
30ca5776c5 | ||
|
|
e18e8c00e7 | ||
|
|
bca4362ca5 | ||
|
|
54c4be10bd | ||
|
|
27f925c05e | ||
|
|
79cb63a1b4 | ||
|
|
5ee0036128 | ||
|
|
316d94166e | ||
|
|
2ca6bb0940 | ||
|
|
ac00c5065f | ||
|
|
441ee9a5ed | ||
|
|
9a41450209 | ||
|
|
dba2ed0b5f | ||
|
|
2079ddf818 | ||
|
|
2135b68106 | ||
|
|
9f98a0aea3 | ||
|
|
1ba39ca7d7 | ||
|
|
262c0c892a | ||
|
|
363bd66a8a | ||
|
|
eccce6dc4a | ||
|
|
31f007ffc2 | ||
|
|
bb45059218 | ||
|
|
71dfe583ed | ||
|
|
84bde7dacd | ||
|
|
81968f6c0c | ||
|
|
f198a46c19 | ||
|
|
c3ea9c15f6 | ||
|
|
8ddb9ce021 | ||
|
|
569d38a137 | ||
|
|
34c189af28 | ||
|
|
ffe2db7f96 | ||
|
|
c5f7926471 | ||
|
|
e008e08105 | ||
|
|
5dd5a7c699 | ||
|
|
347a82eaa9 | ||
|
|
e89823b10e | ||
|
|
6626001dd2 | ||
|
|
b7a7e0504f | ||
|
|
01e1f52a70 | ||
|
|
0b9e861f90 | ||
|
|
bda18e035a | ||
|
|
0d46d48881 | ||
|
|
6f24112b5e | ||
|
|
f4921f1fe9 |
9
.github/workflows/release-cli.yml
vendored
9
.github/workflows/release-cli.yml
vendored
@@ -25,10 +25,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
goos: [linux, freebsd, darwin, windows]
|
goos: [linux, freebsd, darwin, windows]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64, riscv64]
|
||||||
exclude:
|
exclude:
|
||||||
- goarch: arm64
|
- goarch: arm64
|
||||||
goos: windows
|
goos: windows
|
||||||
|
- goarch: riscv64
|
||||||
|
goos: windows
|
||||||
|
- goarch: riscv64
|
||||||
|
goos: darwin
|
||||||
|
- goarch: arm64
|
||||||
|
goos: freebsd
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: wangyoucao577/go-release-action@v1.40
|
- uses: wangyoucao577/go-release-action@v1.40
|
||||||
@@ -36,6 +42,7 @@ jobs:
|
|||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
goos: ${{ matrix.goos }}
|
goos: ${{ matrix.goos }}
|
||||||
goarch: ${{ matrix.goarch }}
|
goarch: ${{ matrix.goarch }}
|
||||||
|
ldflags: -X main.version=${{ github.ref_name }}
|
||||||
overwrite: true
|
overwrite: true
|
||||||
md5sum: false
|
md5sum: false
|
||||||
sha256sum: false
|
sha256sum: false
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
nak
|
nak
|
||||||
|
mnt
|
||||||
|
nak.exe
|
||||||
|
|||||||
197
README.md
197
README.md
@@ -16,7 +16,7 @@ take a look at the help text that comes in it to learn all possibilities, but he
|
|||||||
|
|
||||||
### make a nostr event with custom content and tags, sign it with a different key and publish it to two relays
|
### make a nostr event with custom content and tags, sign it with a different key and publish it to two relays
|
||||||
```shell
|
```shell
|
||||||
~> nak event --sec 02 -c 'good morning' --tag t=gm wss://nostr-pub.wellorder.net wss://relay.damus.io
|
~> nak event --sec 02 -c 'good morning' --tag t=gm nostr-pub.wellorder.net relay.damus.io
|
||||||
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
|
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
|
||||||
publishing to wss://nostr-pub.wellorder.net... success.
|
publishing to wss://nostr-pub.wellorder.net... success.
|
||||||
publishing to wss://relay.damus.io... success.
|
publishing to wss://relay.damus.io... success.
|
||||||
@@ -24,7 +24,7 @@ publishing to wss://relay.damus.io... success.
|
|||||||
|
|
||||||
### query a bunch of relays for a tag with a limit of 2 for each, print their content
|
### query a bunch of relays for a tag with a limit of 2 for each, print their content
|
||||||
```shell
|
```shell
|
||||||
~> nak req -k 1 -t t=gm -l 2 wss://nostr.mom wss://nostr.wine wss://nostr-pub.wellorder.net | jq .content
|
~> nak req -k 1 -t t=gm -l 2 nostr.mom nostr.wine nostr-pub.wellorder.net | jq .content
|
||||||
"#GM, you sovereign savage #freeple of the #nostrverse. Let's cause some #nostroversy. "
|
"#GM, you sovereign savage #freeple of the #nostrverse. Let's cause some #nostroversy. "
|
||||||
"ITM slaves!\n#gm https://image.nostr.build/cbbcdf80bfc302a6678ecf9387c87d87deca3e0e288a12e262926c34feb3f6aa.jpg "
|
"ITM slaves!\n#gm https://image.nostr.build/cbbcdf80bfc302a6678ecf9387c87d87deca3e0e288a12e262926c34feb3f6aa.jpg "
|
||||||
"good morning"
|
"good morning"
|
||||||
@@ -34,13 +34,13 @@ publishing to wss://relay.damus.io... success.
|
|||||||
|
|
||||||
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
|
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
|
||||||
```shell
|
```shell
|
||||||
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r wss://nostr.zbd.gg
|
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
|
||||||
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
|
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
|
||||||
~> nak decode nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
|
~> nak decode nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
|
||||||
{
|
{
|
||||||
"id": "5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59",
|
"id": "5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59",
|
||||||
"relays": [
|
"relays": [
|
||||||
"wss://nostr.zbd.gg"
|
"nostr.zbd.gg"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -61,7 +61,7 @@ nak fetch nevent1qqs2e3k48vtrkzjm8vvyzcmsmkf58unrxtq2k4h5yspay6vhcqm4wqcpz9mhxue
|
|||||||
|
|
||||||
### republish an event from one relay to multiple others
|
### republish an event from one relay to multiple others
|
||||||
```shell
|
```shell
|
||||||
~> nak req -i e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a wss://nostr.wine/ wss://nostr-pub.wellorder.net | nak event wss://nostr.wine wss://offchain.pub wss://public.relaying.io wss://eden.nostr.land wss://atlas.nostr.land wss://relayable.org
|
~> nak req -i e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a nostr.wine/ nostr-pub.wellorder.net | nak event nostr.wine offchain.pub public.relaying.io eden.nostr.land atlas.nostr.land relayable.org
|
||||||
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
|
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
|
||||||
publishing to wss://nostr.wine... failed: msg: blocked: not an active paid member
|
publishing to wss://nostr.wine... failed: msg: blocked: not an active paid member
|
||||||
publishing to wss://offchain.pub... success.
|
publishing to wss://offchain.pub... success.
|
||||||
@@ -77,12 +77,191 @@ publishing to wss://relayable.org... success.
|
|||||||
invalid .id, expected 05bd99d54cb835f427e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a, got 05bd99d54cb835f327e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a
|
invalid .id, expected 05bd99d54cb835f427e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a, got 05bd99d54cb835f327e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a
|
||||||
```
|
```
|
||||||
|
|
||||||
### fetch all quoted events by a given pubkey in their last 100 notes
|
### fetch all quoted events by a given pubkey in their last 10 notes of 2023
|
||||||
```shell
|
```shell
|
||||||
nak req -l 100 -k 1 -a 2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884 wss://relay.damus.io | jq -r '.content | match("nostr:((note1|nevent1)[a-z0-9]+)";"g") | .captures[0].string' | nak decode | jq -cr '{ids: [.id]}' | nak req wss://relay.damus.io
|
~> nak req -l 10 -k 1 --until 'December 31 2023' -a 2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884 relay.damus.io | jq -r '.content | match("nostr:((note1|nevent1)[a-z0-9]+)";"g") | .captures[0].string' | nak decode | jq -cr '{ids: [.id]}' | nak req relay.damus.io
|
||||||
|
connecting to relay.damus.io...
|
||||||
|
ok.
|
||||||
|
{"kind":1,"id":"0000000a5109c9747e3847282fcaef3d221d1be5e864ced7b2099d416a18d15a","pubkey":"7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805","created_at":1703869609,"tags":[["nonce","12912720851599460299","25"]],"content":"https://image.nostr.build/5eb40d3cae799bc572763b8f8bee95643344fa392d280efcb0fd28a935879e2a.png\n\nNostr is not dying.\nIt is just a physiological and healthy slowdown on the part of all those who have made this possible in such a short time, sharing extraordinary enthusiasm. This is necessary to regain a little energy, it will allow some things to be cleaned up and more focused goals to be set.\n\nIt is like the caterpillar that is about to become a butterfly, it has to stop moving, acting, doing all the time; it has to do one last silent work and rest, letting time go by. And then a new phase of life can begin.\n\nWe have an amazing 2024 ahead.\nThank you all, who have given so much and believe in Nostr.\n\nPS: an interesting cue suggested by this image, you cannot have both silk and butterfly, you have to choose: a precious and sophisticated ornament, or the living, colorful beauty of freedom.","sig":"16fe157fb13dba2474d510db5253edc409b465515371015a91b26b8f39e5aa873453bc366947c37463c49466f5fceb7dea0485432f979a03471c8f76b73e553c"}
|
||||||
|
{"kind":1,"id":"ac0cc72dfee39f41d94568f574e7b613d3979facbd7b477a16b52eb763db4b6e","pubkey":"2250f69694c2a43929e77e5de0f6a61ae5e37a1ee6d6a3baef1706ed9901248b","created_at":1703873865,"tags":[["r","https://zine.wavlake.com/2023-year-in-review/"]],"content":"It's been an incredible year for us here at Wavlake and we wanted to take a moment to look back and see how far we've come since launch. Read more.. https://zine.wavlake.com/2023-year-in-review/","sig":"189e354f67f48f3046fd762c83f9bf3a776d502d514e2839a1b459c30107a02453304ef695cdc7d254724041feec3800806b21eb76259df87144aaef821ace5b"}
|
||||||
|
{"kind":1,"id":"6215766c5aadfaf51488134682f7d28f237218b5405da2fc11d1fefe1ebf8154","pubkey":"4ce6abbd68dab6e9fdf6e8e9912a8e12f9b539e078c634c55a9bff2994a514dd","created_at":1703879775,"tags":[["imeta","url https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4","blurhash eHD,QG_4ogMu_3to%O-:MwM_IWRjx^-pIUoe-;t7%Nt7%gV?M{WBxu","dim 480x268"],["t","zaps"],["t","powakili23"],["p","4f82bced42584a6acfced2a657b5acabc4f90d75a95ed3ff888f3b04b8928630"],["p","ce75bae2349804caa5f4de8ae8f775bb558135f412441d9e32f88e4226c5d165"],["p","94bd495b78f8f6e5aff8ebc90e052d3a409d1f9d82e43ab56ca2cafb81b18ddf"],["p","50ff5b7ebeac1cc0d03dc878be8a59f1b63d45a7d5e60ade4b6f6f31eca25954"],["p","f300cf2bdf9808ed229dfa468260753a0b179935bdb87612b6d4f5b9fe3fc7cf"],["r","https://geyser.fund/entry/2636"],["r","https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4"]],"content":"POWA - HQ UPDATE - DEC 2023\nTLDR: plan to open January 2024, 1 million Sats to go to reach milestone. #zaps go to fund this project. ⚡️powa@geyser.fund\n\nHello,\n\nFirst and foremost, I’d like to thank you for the incredible support shown for this project. It’s been an absolute honor to oversee this Proof of Work initiative.\n\nI am thrilled to announce that we are right on track for the grand opening in January 2024.\n\nCurrently, we're just over 1 million Sats away from reaching our target for this phase.\n\nPlease take a moment to enjoy the video and stay tuned for further updates about POWA. \n\nMan Like Who?\nMan Like Kweks!\n🇹🇿⚡️💜🏔️\n#powakili23\nnostr:npub1f7ptem2ztp9x4n7w62n90ddv40z0jrt4490d8lug3uasfwyjsccqkknerm nostr:npub1ee6m4c35nqzv4f05m69w3am4hd2czd05zfzpm83jlz8yyfk969js78tfcv nostr:npub1jj75jkmclrmwttlca0ysupfd8fqf68uastjr4dtv5t90hqd33h0s4gcksp nostr:npub12rl4kl474swvp5paeputazje7xmr63d86hnq4hjtdahnrm9zt92qgq500s nostr:npub17vqv727lnqyw6g5alfrgycr48g930xf4hku8vy4k6n6mnl3lcl8sglecc5 \n\nhttps://geyser.fund/entry/2636 https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4 ","sig":"97d13c17d91c319f343cc770222d6d4a0a714d0e7e4ef43373adaf215a4c077f0bdf12bac488c74dbd4d55718d46c17a617b93c8660736b70bcd61a8820ece67"}
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing to this repository
|
### sign an event collaboratively with multiple parties using musig2
|
||||||
|
```shell
|
||||||
|
~> nak event --sec 1234 -k 1 -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==
|
||||||
|
|
||||||
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3vamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet5qgsg04q5ypr6f4n65mv7e5hs05z50hy7vvgua8uc8szwtp262cfwn6srqsqqqauedy5x7y`.
|
the next signer and they should call this on their side:
|
||||||
|
nak event --sec <insert-secret-key> --musig 2 -k 1 -ts 1720821287 -c 'hello from a combined key' --musig-pubkey 0337a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba --musig-nonce 0285af37c6c43638cda2c773098e867c749ddf1e9d096b78686c5d000603935ad3025c4a1e042eb6b0dcfd864d1e072d2ce8da06f2c0dcf13fd7d1fcef0dd26dbc92
|
||||||
|
```
|
||||||
|
|
||||||
|
demo videos with [2](https://njump.me/nevent1qqs8pmmae89agph80928l6gjm0wymechqazv80jwqrqy4cgk08epjaczyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze674zkzz), [3](https://njump.me/nevent1qqsrp320drqcnmnam6jvmdd4lgdvh2ay0xrdesrvy6q9qqdfsk7r55qzyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze6c32d4m) and [4](https://njump.me/nevent1qqsre84xe6qpagf2w2xjtjwc95j4dd5ccue68gxl8grkd6t6hjhaj5qzyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze6t8t7ak) parties.
|
||||||
|
|
||||||
|
### generate a private key
|
||||||
|
```shell
|
||||||
|
~> nak key generate 18:59
|
||||||
|
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
|
||||||
|
```
|
||||||
|
|
||||||
|
### encrypt key with NIP-49
|
||||||
|
```shell
|
||||||
|
~> nak key encrypt 7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d mypassword
|
||||||
|
ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls
|
||||||
|
```
|
||||||
|
|
||||||
|
### decrypt key with NIP-49
|
||||||
|
```shell
|
||||||
|
~> nak key decrypt ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls mypassword
|
||||||
|
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
|
||||||
|
~>
|
||||||
|
~> nak key decrypt ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls
|
||||||
|
type the password to decrypt your secret key: **********
|
||||||
|
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
|
||||||
|
```
|
||||||
|
|
||||||
|
### get a public key from a private key
|
||||||
|
```shell
|
||||||
|
~> nak key public 7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
|
||||||
|
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
|
||||||
|
```
|
||||||
|
|
||||||
|
### sign an event using a remote NIP-46 bunker
|
||||||
|
```shell
|
||||||
|
~> nak event --connect 'bunker://a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Frelay.nsecbunker.com&relay=wss%3A%2F%2Fnos.lol&secret=TWfGbjQCLxUf' -c 'hello from bunker'
|
||||||
|
```
|
||||||
|
|
||||||
|
### sign an event using a NIP-49 encrypted key
|
||||||
|
```shell
|
||||||
|
~> nak event --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls -c 'hello from encrypted key'
|
||||||
|
type the password to decrypt your secret key: **********
|
||||||
|
{"kind":1,"id":"8aa5c931fb1da507f14801de6a1814b7f0baae984dc502b9889f347f5aa3cc4e","pubkey":"985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7","created_at":1720822280,"tags":[],"content":"hello from encrypted key","sig":"9d1c9e56e87f787cc5b6191ec47690ce59fa4bef105b56297484253953e18fb930f6683f007e84a9ce9dc9a25b20c191c510629156dcd24bd16e15d302d20944"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### talk to a relay's NIP-86 management API
|
||||||
|
```shell
|
||||||
|
nak relay allowpubkey --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls --pubkey a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208 pyramid.fiatjaf.com
|
||||||
|
type the password to decrypt your secret key: **********
|
||||||
|
calling 'allowpubkey' on https://pyramid.fiatjaf.com...
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"error": "failed to add to whitelist: pubkey 985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7 doesn't have permission to invite"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### start a bunker locally
|
||||||
|
```shell
|
||||||
|
~> nak bunker --sec ncryptsec1qggrp80ptf0s7kyl0r38ktzg60fem85m89uz7um6rjn4pnep2nnvcgqm8h7q36c76z9sypatdh4fmw6etfxu99mv5cxkw4ymcsryw0zz7evyuplsgvnj5yysf449lq94klzvnahsw2lzxflvcq4qpf5q -k 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed relay.damus.io nos.lol relay.nsecbunker.com
|
||||||
|
connecting to relay.damus.io... ok.
|
||||||
|
connecting to nos.lol... ok.
|
||||||
|
connecting to relay.nsecbunker.com... ok.
|
||||||
|
type the password to decrypt your secret key: ***
|
||||||
|
listening at [wss://relay.damus.io wss://nos.lol wss://relay.nsecbunker.com]:
|
||||||
|
pubkey: f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a
|
||||||
|
npub: npub17kv3rdtpcd7fpvq7newz24eswwqgxhyr8xt4daxk9kqkwgn7gg9q4gy8vf
|
||||||
|
authorized keys:
|
||||||
|
- 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed
|
||||||
|
to restart: nak bunker --sec ncryptsec1qggrp80ptf0s7kyl0r38ktzg60fem85m89uz7um6rjn4pnep2nnvcgqm8h7q36c76z9sypatdh4fmw6etfxu99mv5cxkw4ymcsryw0zz7evyuplsgvnj5yysf449lq94klzvnahsw2lzxflvcq4qpf5q -k 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed relay.damus.io nos.lol relay.nsecbunker.com
|
||||||
|
bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Fnos.lol&relay=wss%3A%2F%2Frelay.nsecbunker.com&secret=XuuiMbcLwuwL
|
||||||
|
```
|
||||||
|
|
||||||
|
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
|
||||||
|
```shell
|
||||||
|
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
|
||||||
|
{"kind":1,"id":"f030fccd90c783858dfcee204af94826cf0f1c85d6fc85a0087e9e5172419393","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1719677535,"tags":[["-"],["e","f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a","wss://relay.whatever.com","root","a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208"],["p","a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208","wss://p-relay.com"]],"content":"I know the future","sig":"8b36a74e29df8bc12bed66896820da6940d4d9409721b3ed2e910c838833a178cb45fd5bb1c6eb6adc66ab2808bfac9f6644a2c55a6570bb2ad90f221c9c7551"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### download the latest 50000 notes from a relay, regardless of their natural query limits, by paginating requests
|
||||||
|
```shell
|
||||||
|
~> nak req -k 1 --limit 50000 --paginate --paginate-interval 2s nos.lol > events.jsonl
|
||||||
|
~> wc -l events.jsonl
|
||||||
|
50000 events.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
### run a somewhat verbose local relay for test purposes
|
||||||
|
```shell
|
||||||
|
~> nak serve
|
||||||
|
> relay running at ws://localhost:10547
|
||||||
|
got request {"kinds":[1],"authors":["79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"],"since":1724082362}
|
||||||
|
got event {"kind":1,"id":"e3c6bf630d6deea74c0ee2f7f7ba6da55a627498a32f1e72029229bb1810bce3","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082366,"tags":[],"content":"two","sig":"34261cf226c3fee2df24e55a89f43f5349c98a64bce46bdc46807b0329f334cea93e9e8bc285c1259a5684cf23f5e507c8e6dad47a31a6615d706b1130d09e69"}
|
||||||
|
got event {"kind":1,"id":"0bbb397c8f87ae557650b9d6ee847292df8e530c458ffea1b24bdcb7bed0ec5e","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082369,"tags":[],"content":"three","sig":"aa1cb7d5f0f03f358fc4c0a4351a4f1c66e3a7627021b618601c56ba598b825b6d95d9c8720a4c60666a7eb21e17018cf326222f9f574a9396f2f2da7f007546"}
|
||||||
|
• events stored: 2, subscriptions opened: 1
|
||||||
|
got event {"kind":1,"id":"029ebff759dd54dbd01b929f879fea5802de297e1c3768ca16d9b97cc8bca38f","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082371,"tags":[],"content":"four","sig":"9816de517d87d4c3ede57c1c50e3c237486794241afadcd891e1acbba2c5e672286090e6ad3402b047d69bae8095bc4e20e57ac70d92386dfa26db216379330f"}
|
||||||
|
got event {"kind":1,"id":"fe6489fa6fbb925be839377b9b7049d73be755dc2bdad97ff6dd9eecbf8b3a32","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082383,"tags":[],"content":"five","sig":"865ce5e32eead5bdb950ac1fbc55bc92dde26818ee3136634538ec42914de179a51e672c2d4269d4362176e5e8cd5e08e69b35b91c6c2af867e129b93d607635"}
|
||||||
|
got request {"kinds":[30818]}
|
||||||
|
• events stored: 4, subscriptions opened: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### make an event with a PoW target
|
||||||
|
```shell
|
||||||
|
~> nak event -c 'hello getwired.app and labour.fiatjaf.com' --pow 24
|
||||||
|
{"kind":1,"id":"0000009dcc7c62056eafdb41fac817379ec2becf0ce27c5fbe98d0735d968147","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724160828,"tags":[["nonce","515504","24"]],"content":"hello getwired.app and labour.fiatjaf.com","sig":"7edb988065ccc12779fe99270945b212f3723838f315d76d5e90e9ffa27198f13fa556614295f518d968d55bab81878167d4162b3a7cf81a6b423c6761bd504c"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### make a nostr event signed with a key given as an environment variable
|
||||||
|
|
||||||
|
```shell
|
||||||
|
~> export NOSTR_SECRET_KEY=ncryptsec1qggyy9vw0nclmw8ly9caz6aa7f85a4ufhsct64uva337pulsdw00n6twa2lzhzk2znzsyu60urx9s08lx00ke6ual3lszyn5an9zarm6s70lw5lj6dv3mj3f9p4tvp0we6qyz4gp420mapfmvqheuttv
|
||||||
|
~> nak event -c 'it supports keys as hex, nsec or ncryptsec'
|
||||||
|
type the password to decrypt your secret key: ********
|
||||||
|
{"kind":1,"id":"5cbf3feb9a7d99c3ee2a88693a591caca1a8348fea427b3652c27f7a8a76af48","pubkey":"b00bcab55375d8c7b731dd9841f6d805ff1cf6fdc945e7326786deb5ddac6ce4","created_at":1724247924,"tags":[],"content":"it supports keys as hex, nsec or ncryptsec","sig":"fb3fd170bc10e5042322c7a05dd4bbd8ac9947b39026b8a7afd1ee02524e8e3aa1d9554e9c7b6181ca1b45cab01cd06643bdffa5ce678b475e6b185e1c14b085"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### download some helpful `jq` functions for dealing with nostr events
|
||||||
|
```shell
|
||||||
|
~> nak req -i 412f2d3e73acc312942c055ac2a695dc60bf58ff97e06689a8a79e97796c4cdb relay.westernbtc.com | jq -r .content > ~/.jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### watch a NIP-53 livestream (zap.stream etc)
|
||||||
|
```shell
|
||||||
|
~> # this requires the jq utils from the step above
|
||||||
|
~> mpv $(nak fetch naddr1qqjxvvm9xscnsdtx95cxvcfk956rsvtx943rje3k95mx2dp389jnwwrp8ymxgqg4waehxw309aex2mrp0yhxgctdw4eju6t09upzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qvzqqqrkvu7ed38k | jq -r 'tag_value("streaming")')
|
||||||
|
~>
|
||||||
|
~> # or without the utils
|
||||||
|
~> mpv $(nak fetch naddr1qqjxvvm9xscnsdtx95cxvcfk956rsvtx943rje3k95mx2dp389jnwwrp8ymxgqg4waehxw309aex2mrp0yhxgctdw4eju6t09upzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qvzqqqrkvu7ed38k | jq -r '.tags | map(select(.[0] == "streaming") | .[1])[0]')
|
||||||
|
```
|
||||||
|
|
||||||
|
### download a NIP-35 torrent from an `nevent`
|
||||||
|
```shell
|
||||||
|
~> # this requires the jq utils from two steps above
|
||||||
|
~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"')
|
||||||
|
```
|
||||||
|
|
||||||
|
### mount Nostr as a FUSE filesystem and publish a note
|
||||||
|
```shell
|
||||||
|
~> nak fs --sec 01 ~/nostr
|
||||||
|
- mounting at /home/user/nostr... ok.
|
||||||
|
~> cd ~/nostr/npub1xxxxxx/notes/
|
||||||
|
~> echo "satellites are bad!" > new
|
||||||
|
pending note updated, timer reset.
|
||||||
|
- `touch publish` to publish immediately
|
||||||
|
- `rm new` to erase and cancel the publication.
|
||||||
|
~> touch publish
|
||||||
|
publishing now!
|
||||||
|
{"id":"f1cbfa6...","pubkey":"...","content":"satellites are bad!","sig":"..."}
|
||||||
|
publishing to 3 relays... offchain.pub: ok, nostr.wine: ok, pyramid.fiatjaf.com: ok
|
||||||
|
event published as f1cbfa6... and updated locally.
|
||||||
|
```
|
||||||
|
|
||||||
|
### list NIP-60 wallet tokens and send some
|
||||||
|
```shell
|
||||||
|
~> nak wallet tokens
|
||||||
|
91a10b6fc8bbe7ef2ad9ad0142871d80468b697716d9d2820902db304ff1165e 500 cashu.space
|
||||||
|
cac7f89f0611021984d92a7daca219e4cd1c9798950e50e952bba7cde1ac1337 1000 legend.lnbits.com
|
||||||
|
~> nak wallet send 100
|
||||||
|
cashuA1psxqyry8...
|
||||||
|
~> nak wallet pay lnbc1...
|
||||||
|
```
|
||||||
|
|
||||||
|
### upload and download files with blossom
|
||||||
|
```shell
|
||||||
|
~> nak blossom --server blossom.azzamo.net --sec 01 upload image.png
|
||||||
|
{"sha256":"38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf","url":"https://blossom.azzamo.net/38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf.png"}
|
||||||
|
~> nak blossom --server aegis.utxo.one download acc8ea43d4e6b706f68b249144364f446854b7f63ba1927371831c05dcf0256c -o downloaded.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## contributing to this repository
|
||||||
|
|
||||||
|
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`.
|
||||||
|
|||||||
222
blossom.go
Normal file
222
blossom.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr/keyer"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nipb0/blossom"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var blossomCmd = &cli.Command{
|
||||||
|
Name: "blossom",
|
||||||
|
Suggest: true,
|
||||||
|
UseShortOptionHandling: true,
|
||||||
|
Usage: "an army knife for blossom things",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(defaultKeyFlags,
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "server",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "the hostname of the target mediaserver",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "lists blobs from a pubkey",
|
||||||
|
Description: `takes one pubkey passed as an argument or derives one from the --sec supplied. if that is given then it will also pre-authorize the list, which some servers may require.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "[pubkey]",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
var client *blossom.Client
|
||||||
|
pubkey := c.Args().First()
|
||||||
|
if pubkey != "" {
|
||||||
|
client = blossom.NewClient(client.GetMediaServer(), keyer.NewReadOnlySigner(pubkey))
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
client, err = getBlossomClient(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bds, err := client.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bd := range bds {
|
||||||
|
stdout(bd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "upload",
|
||||||
|
Usage: "uploads a file to a specific mediaserver.",
|
||||||
|
Description: `takes any number of local file paths and uploads them to a mediaserver, printing the resulting blob descriptions when successful.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "[files...]",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
client, err := getBlossomClient(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
for _, fpath := range c.Args().Slice() {
|
||||||
|
bd, err := client.UploadFile(ctx, fpath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
hasError = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
j, _ := json.Marshal(bd)
|
||||||
|
stdout(string(j))
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasError {
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "download",
|
||||||
|
Usage: "downloads files from mediaservers",
|
||||||
|
Description: `takes any number of sha256 hashes as hex, downloads them and prints them to stdout (unless --output is specified).`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "[sha256...]",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "output",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Usage: "file name to save downloaded file to, can be passed multiple times when downloading multiple hashes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
client, err := getBlossomClient(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs := c.StringSlice("output")
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
for i, hash := range c.Args().Slice() {
|
||||||
|
if len(outputs)-1 >= i && outputs[i] != "--" {
|
||||||
|
// save to this file
|
||||||
|
err := client.DownloadToFile(ctx, hash, outputs[i])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if output wasn't specified, print to stdout
|
||||||
|
data, err := client.Download(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
hasError = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
os.Stdout.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasError {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "del",
|
||||||
|
Aliases: []string{"delete"},
|
||||||
|
Usage: "deletes a file from a mediaserver",
|
||||||
|
Description: `takes any number of sha256 hashes, signs authorizations and deletes them from the current mediaserver.
|
||||||
|
|
||||||
|
if any of the files are not deleted command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it successfully deletes to stdout.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "[sha256...]",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
client, err := getBlossomClient(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
for _, hash := range c.Args().Slice() {
|
||||||
|
err := client.Delete(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
hasError = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasError {
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "check",
|
||||||
|
Usage: "asks the mediaserver if it has the specified hashes.",
|
||||||
|
Description: `uses the HEAD request to succintly check if the server has the specified sha256 hash.
|
||||||
|
|
||||||
|
if any of the files are not found the command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it finds to stdout.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "[sha256...]",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
client, err := getBlossomClient(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
for _, hash := range c.Args().Slice() {
|
||||||
|
err := client.Check(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
hasError = true
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasError {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "mirror",
|
||||||
|
Usage: "",
|
||||||
|
Description: ``,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, error) {
|
||||||
|
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return blossom.NewClient(c.String("server"), keyer), nil
|
||||||
|
}
|
||||||
241
bunker.go
241
bunker.go
@@ -1,47 +1,55 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/fatih/color"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
"github.com/nbd-wtf/go-nostr/nip46"
|
"github.com/nbd-wtf/go-nostr/nip46"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var bunker = &cli.Command{
|
var bunker = &cli.Command{
|
||||||
Name: "bunker",
|
Name: "bunker",
|
||||||
Usage: "starts a NIP-46 signer daemon with the given --sec key",
|
Usage: "starts a nip46 signer daemon with the given --sec key",
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
Description: ``,
|
Description: ``,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "sec",
|
Name: "sec",
|
||||||
Usage: "secret key to sign the event, as hex or nsec",
|
Usage: "secret key to sign the event, as hex or nsec",
|
||||||
DefaultText: "the key '1'",
|
DefaultText: "the key '1'",
|
||||||
Value: "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "prompt-sec",
|
Name: "prompt-sec",
|
||||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "yes",
|
Name: "authorized-secrets",
|
||||||
Aliases: []string{"y"},
|
Aliases: []string{"s"},
|
||||||
Usage: "always respond to any NIP-46 requests from anyone",
|
Usage: "secrets for which we will always respond",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "authorized-keys",
|
||||||
|
Aliases: []string{"k"},
|
||||||
|
Usage: "pubkeys for which we will always respond",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
qs := url.Values{}
|
qs := url.Values{}
|
||||||
relayURLs := make([]string, 0, c.Args().Len())
|
relayURLs := make([]string, 0, c.Args().Len())
|
||||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||||
_, relays := connectToAllRelays(c.Context, relayUrls)
|
relays := connectToAllRelays(ctx, c, relayUrls, nil)
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
@@ -56,85 +64,184 @@ var bunker = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gather the secret key
|
// gather the secret key
|
||||||
sec, err := gatherSecretKeyFromArguments(c)
|
sec, _, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// other arguments
|
||||||
|
authorizedKeys := c.StringSlice("authorized-keys")
|
||||||
|
authorizedSecrets := c.StringSlice("authorized-secrets")
|
||||||
|
|
||||||
|
// this will be used to auto-authorize the next person who connects who isn't pre-authorized
|
||||||
|
// it will be stored
|
||||||
|
newSecret := randString(12)
|
||||||
|
|
||||||
|
// static information
|
||||||
pubkey, err := nostr.GetPublicKey(sec)
|
pubkey, err := nostr.GetPublicKey(sec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
npub, _ := nip19.EncodePublicKey(pubkey)
|
npub, _ := nip19.EncodePublicKey(pubkey)
|
||||||
log("listening at %s%v%s:\n %spubkey:%s %s\n %snpub:%s %s\n %sconnection code:%s %s\n %sbunker:%s %s\n\n",
|
|
||||||
BOLD_ON, relayURLs, BOLD_OFF,
|
|
||||||
BOLD_ON, BOLD_OFF, pubkey,
|
|
||||||
BOLD_ON, BOLD_OFF, npub,
|
|
||||||
BOLD_ON, BOLD_OFF, fmt.Sprintf("%s#secret?%s", npub, qs.Encode()),
|
|
||||||
BOLD_ON, BOLD_OFF, fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode()),
|
|
||||||
)
|
|
||||||
|
|
||||||
alwaysYes := c.Bool("yes")
|
// this function will be called every now and then
|
||||||
|
printBunkerInfo := func() {
|
||||||
|
qs.Set("secret", newSecret)
|
||||||
|
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode())
|
||||||
|
|
||||||
|
authorizedKeysStr := ""
|
||||||
|
if len(authorizedKeys) != 0 {
|
||||||
|
authorizedKeysStr = "\n authorized keys:\n - " + colors.italic(strings.Join(authorizedKeys, "\n - "))
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizedSecretsStr := ""
|
||||||
|
if len(authorizedSecrets) != 0 {
|
||||||
|
authorizedSecretsStr = "\n authorized secrets:\n - " + colors.italic(strings.Join(authorizedSecrets, "\n - "))
|
||||||
|
}
|
||||||
|
|
||||||
|
preauthorizedFlags := ""
|
||||||
|
for _, k := range authorizedKeys {
|
||||||
|
preauthorizedFlags += " -k " + k
|
||||||
|
}
|
||||||
|
for _, s := range authorizedSecrets {
|
||||||
|
preauthorizedFlags += " -s " + s
|
||||||
|
}
|
||||||
|
|
||||||
|
secretKeyFlag := ""
|
||||||
|
if sec := c.String("sec"); sec != "" {
|
||||||
|
secretKeyFlag = "--sec " + sec
|
||||||
|
}
|
||||||
|
|
||||||
|
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
|
||||||
|
for i, url := range relayURLs {
|
||||||
|
if strings.HasPrefix(url, "wss://") {
|
||||||
|
relayURLsPossiblyWithoutSchema[i] = url[6:]
|
||||||
|
} else {
|
||||||
|
relayURLsPossiblyWithoutSchema[i] = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
|
||||||
|
secretKeyFlag,
|
||||||
|
preauthorizedFlags,
|
||||||
|
strings.Join(relayURLsPossiblyWithoutSchema, " "),
|
||||||
|
)
|
||||||
|
|
||||||
|
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
||||||
|
colors.bold(relayURLs),
|
||||||
|
colors.bold(pubkey),
|
||||||
|
colors.bold(npub),
|
||||||
|
authorizedKeysStr,
|
||||||
|
authorizedSecretsStr,
|
||||||
|
color.CyanString(restartCommand),
|
||||||
|
colors.bold(bunkerURI),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
printBunkerInfo()
|
||||||
|
|
||||||
// subscribe to relays
|
// subscribe to relays
|
||||||
pool := nostr.NewSimplePool(c.Context)
|
now := nostr.Now()
|
||||||
events := pool.SubMany(c.Context, relayURLs, nostr.Filters{
|
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
|
||||||
{
|
Kinds: []int{nostr.KindNostrConnect},
|
||||||
Kinds: []int{24133},
|
Tags: nostr.TagMap{"p": []string{pubkey}},
|
||||||
Tags: nostr.TagMap{"p": []string{pubkey}},
|
Since: &now,
|
||||||
},
|
LimitZero: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
signer := nip46.NewStaticKeySigner(sec)
|
signer := nip46.NewStaticKeySigner(sec)
|
||||||
|
handlerWg := sync.WaitGroup{}
|
||||||
|
printLock := sync.Mutex{}
|
||||||
|
|
||||||
|
// just a gimmick
|
||||||
|
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||||
|
_, cancel := context.WithCancel(ctx)
|
||||||
|
cancelPreviousBunkerInfoPrint = cancel
|
||||||
|
|
||||||
|
// asking user for authorization
|
||||||
|
signer.AuthorizeRequest = func(harmless bool, from string, secret string) bool {
|
||||||
|
if secret == newSecret {
|
||||||
|
// store this key
|
||||||
|
authorizedKeys = append(authorizedKeys, from)
|
||||||
|
// discard this and generate a new secret
|
||||||
|
newSecret = randString(12)
|
||||||
|
// print bunker info again after this
|
||||||
|
go func() {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
printBunkerInfo()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
||||||
|
}
|
||||||
|
|
||||||
for ie := range events {
|
for ie := range events {
|
||||||
req, resp, eventResponse, harmless, err := signer.HandleRequest(ie.Event)
|
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||||
|
|
||||||
|
// handle the NIP-46 request event
|
||||||
|
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log("< failed to handle request from %s: %s", ie.Event.PubKey, err.Error())
|
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
jreq, _ := json.MarshalIndent(req, " ", " ")
|
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||||
log("- got request from '%s': %s\n", ie.Event.PubKey, string(jreq))
|
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey), string(jreq))
|
||||||
jresp, _ := json.MarshalIndent(resp, " ", " ")
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
log("~ responding with %s\n", string(jresp))
|
log("~ responding with %s\n", string(jresp))
|
||||||
|
|
||||||
if alwaysYes || harmless || askProceed(ie.Event.PubKey) {
|
handlerWg.Add(len(relayURLs))
|
||||||
if err := ie.Relay.Publish(c.Context, eventResponse); err == nil {
|
for _, relayURL := range relayURLs {
|
||||||
log("* sent response!\n")
|
go func(relayURL string) {
|
||||||
} else {
|
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
||||||
log("* failed to send response: %s\n", err)
|
err := relay.Publish(ctx, eventResponse)
|
||||||
}
|
printLock.Lock()
|
||||||
|
if err == nil {
|
||||||
|
log("* sent response through %s\n", relay.URL)
|
||||||
|
} else {
|
||||||
|
log("* failed to send response: %s\n", err)
|
||||||
|
}
|
||||||
|
printLock.Unlock()
|
||||||
|
handlerWg.Done()
|
||||||
|
}
|
||||||
|
}(relayURL)
|
||||||
}
|
}
|
||||||
|
handlerWg.Wait()
|
||||||
|
|
||||||
|
// just after handling one request we trigger this
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
cancelPreviousBunkerInfoPrint = cancel
|
||||||
|
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
|
||||||
|
// but we will only do if the bunker is inactive for more than 5 minutes
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(time.Minute * 5):
|
||||||
|
log("\n")
|
||||||
|
printBunkerInfo()
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "connect",
|
||||||
|
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||||
|
ArgsUsage: "<nostrconnect-uri>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
if c.Args().Len() != 1 {
|
||||||
|
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||||
|
}
|
||||||
|
|
||||||
var allowedSources = make([]string, 0, 2)
|
uri, err := url.Parse(c.Args().First())
|
||||||
|
if err != nil || uri.Scheme != "nostrconnect" || !nostr.IsValidPublicKey(uri.Host) {
|
||||||
|
return fmt.Errorf("invalid uri")
|
||||||
|
}
|
||||||
|
|
||||||
func askProceed(source string) bool {
|
return nil
|
||||||
if slices.Contains(allowedSources, source) {
|
},
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := promptui.Select{
|
|
||||||
Label: "proceed?",
|
|
||||||
Items: []string{
|
|
||||||
"no",
|
|
||||||
"yes",
|
|
||||||
"always from this source",
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
n, _, _ := prompt.Run()
|
|
||||||
switch n {
|
|
||||||
case 0:
|
|
||||||
return false
|
|
||||||
case 1:
|
|
||||||
return true
|
|
||||||
case 2:
|
|
||||||
allowedSources = append(allowedSources, source)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
83
count.go
83
count.go
@@ -1,19 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/nbd-wtf/go-nostr/nip45"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var count = &cli.Command{
|
var count = &cli.Command{
|
||||||
Name: "count",
|
Name: "count",
|
||||||
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
|
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
|
||||||
Description: `outputs a NIP-45 request (the flags are mostly the same as 'nak req').`,
|
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "author",
|
Name: "author",
|
||||||
@@ -63,7 +66,30 @@ var count = &cli.Command{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
biggerUrlSize := 0
|
||||||
|
relayUrls := c.Args().Slice()
|
||||||
|
if len(relayUrls) > 0 {
|
||||||
|
relays := connectToAllRelays(ctx, c, relayUrls, nil)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
log("failed to connect to any of the given relays.\n")
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
relayUrls = make([]string, len(relays))
|
||||||
|
for i, relay := range relays {
|
||||||
|
relayUrls[i] = relay.URL
|
||||||
|
if len(relay.URL) > biggerUrlSize {
|
||||||
|
biggerUrlSize = len(relay.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for _, relay := range relays {
|
||||||
|
relay.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
filter := nostr.Filter{}
|
filter := nostr.Filter{}
|
||||||
|
|
||||||
if authors := c.StringSlice("author"); len(authors) > 0 {
|
if authors := c.StringSlice("author"); len(authors) > 0 {
|
||||||
@@ -72,14 +98,18 @@ var count = &cli.Command{
|
|||||||
if ids := c.StringSlice("id"); len(ids) > 0 {
|
if ids := c.StringSlice("id"); len(ids) > 0 {
|
||||||
filter.IDs = ids
|
filter.IDs = ids
|
||||||
}
|
}
|
||||||
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
|
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
|
||||||
|
kinds := make([]int, len(kinds64))
|
||||||
|
for i, v := range kinds64 {
|
||||||
|
kinds[i] = int(v)
|
||||||
|
}
|
||||||
filter.Kinds = kinds
|
filter.Kinds = kinds
|
||||||
}
|
}
|
||||||
|
|
||||||
tags := make([][]string, 0, 5)
|
tags := make([][]string, 0, 5)
|
||||||
for _, tagFlag := range c.StringSlice("tag") {
|
for _, tagFlag := range c.StringSlice("tag") {
|
||||||
spl := strings.SplitN(tagFlag, "=", 2)
|
spl := strings.SplitN(tagFlag, "=", 2)
|
||||||
if len(spl) == 2 && len(spl[0]) == 1 {
|
if len(spl) == 2 {
|
||||||
tags = append(tags, spl)
|
tags = append(tags, spl)
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||||
@@ -110,29 +140,38 @@ var count = &cli.Command{
|
|||||||
filter.Until = &ts
|
filter.Until = &ts
|
||||||
}
|
}
|
||||||
if limit := c.Int("limit"); limit != 0 {
|
if limit := c.Int("limit"); limit != 0 {
|
||||||
filter.Limit = limit
|
filter.Limit = int(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
relays := c.Args().Slice()
|
|
||||||
successes := 0
|
successes := 0
|
||||||
failures := make([]error, 0, len(relays))
|
if len(relayUrls) > 0 {
|
||||||
if len(relays) > 0 {
|
var hll *hyperloglog.HyperLogLog
|
||||||
for _, relayUrl := range relays {
|
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||||
relay, err := nostr.RelayConnect(c.Context, relayUrl)
|
hll = hyperloglog.New(offset)
|
||||||
|
}
|
||||||
|
for _, relayUrl := range relayUrls {
|
||||||
|
relay, _ := sys.Pool.EnsureRelay(relayUrl)
|
||||||
|
count, hllRegisters, err := relay.Count(ctx, nostr.Filters{filter})
|
||||||
|
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failures = append(failures, err)
|
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
count, err := relay.Count(c.Context, nostr.Filters{filter})
|
|
||||||
if err != nil {
|
var hasHLLStr string
|
||||||
failures = append(failures, err)
|
if hll != nil && len(hllRegisters) == 256 {
|
||||||
continue
|
hll.MergeRegisters(hllRegisters)
|
||||||
|
hasHLLStr = " 📋"
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: %d\n", relay.URL, count)
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||||
successes++
|
successes++
|
||||||
}
|
}
|
||||||
if successes == 0 {
|
if successes == 0 {
|
||||||
return errors.Join(failures...)
|
return fmt.Errorf("all relays have failed")
|
||||||
|
} else if hll != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// no relays given, will just print the filter
|
// no relays given, will just print the filter
|
||||||
|
|||||||
132
curl.go
Normal file
132
curl.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var curlFlags []string
|
||||||
|
|
||||||
|
var curl = &cli.Command{
|
||||||
|
Name: "curl",
|
||||||
|
Usage: "calls curl but with a nip98 header",
|
||||||
|
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cowboy parsing of curl flags to get the data we need for nip98
|
||||||
|
var url string
|
||||||
|
var method string
|
||||||
|
var presumedMethod string
|
||||||
|
|
||||||
|
curlBodyBuildingFlags := []string{
|
||||||
|
"-d",
|
||||||
|
"--data",
|
||||||
|
"--data-binary",
|
||||||
|
"--data-ascii",
|
||||||
|
"--data-raw",
|
||||||
|
"--data-urlencode",
|
||||||
|
"-F",
|
||||||
|
"--form",
|
||||||
|
"--form-string",
|
||||||
|
"--form-escape",
|
||||||
|
"--upload-file",
|
||||||
|
}
|
||||||
|
|
||||||
|
nextIsMethod := false
|
||||||
|
for _, f := range curlFlags {
|
||||||
|
if nextIsMethod {
|
||||||
|
method = f
|
||||||
|
method, _ = strings.CutPrefix(method, `"`)
|
||||||
|
method, _ = strings.CutSuffix(method, `"`)
|
||||||
|
method = strings.ToUpper(method)
|
||||||
|
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
|
||||||
|
url = f
|
||||||
|
} else if f == "--request" || f == "-X" {
|
||||||
|
nextIsMethod = true
|
||||||
|
continue
|
||||||
|
} else if slices.Contains(curlBodyBuildingFlags, f) ||
|
||||||
|
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
|
||||||
|
return strings.HasPrefix(f, s)
|
||||||
|
}) {
|
||||||
|
presumedMethod = "POST"
|
||||||
|
}
|
||||||
|
nextIsMethod = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("can't create nip98 event: target url is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "" {
|
||||||
|
if presumedMethod != "" {
|
||||||
|
method = presumedMethod
|
||||||
|
} else {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make and sign event
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: 27235,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"u", url},
|
||||||
|
{"method", method},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first 2 indexes of curlFlags were reserved for this
|
||||||
|
curlFlags[0] = "-H"
|
||||||
|
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
|
||||||
|
|
||||||
|
// call curl
|
||||||
|
cmd := exec.Command("curl", curlFlags...)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Run()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func realCurl() error {
|
||||||
|
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
|
||||||
|
keyFlags := make([]string, 0, 5)
|
||||||
|
|
||||||
|
for i := 0; i < len(os.Args[2:]); i++ {
|
||||||
|
arg := os.Args[i+2]
|
||||||
|
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
|
||||||
|
bareArg, _ := strings.CutPrefix(arg, "-")
|
||||||
|
bareArg, _ = strings.CutPrefix(bareArg, "-")
|
||||||
|
return slices.Contains(f.Names(), bareArg)
|
||||||
|
}) {
|
||||||
|
keyFlags = append(keyFlags, arg)
|
||||||
|
if arg != "--prompt-sec" {
|
||||||
|
i++
|
||||||
|
val := os.Args[i+2]
|
||||||
|
keyFlags = append(keyFlags, val)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
curlFlags = append(curlFlags, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return curl.Run(context.Background(), keyFlags)
|
||||||
|
}
|
||||||
38
decode.go
38
decode.go
@@ -1,14 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
sdk "github.com/nbd-wtf/nostr-sdk"
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var decode = &cli.Command{
|
var decode = &cli.Command{
|
||||||
@@ -19,6 +19,7 @@ var decode = &cli.Command{
|
|||||||
nak decode nevent1qqs29yet5tp0qq5xu5qgkeehkzqh5qu46739axzezcxpj4tjlkx9j7gpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5sh59ud
|
nak decode nevent1qqs29yet5tp0qq5xu5qgkeehkzqh5qu46739axzezcxpj4tjlkx9j7gpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5sh59ud
|
||||||
nak decode nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpz4mhxue69uhk2er9dchxummnw3ezumrpdejqz8thwden5te0dehhxarj94c82c3wwajkcmr0wfjx2u3wdejhgqgcwaehxw309aex2mrp0yhxummnw3exzarf9e3k7mgnp0sh5
|
nak decode nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpz4mhxue69uhk2er9dchxummnw3ezumrpdejqz8thwden5te0dehhxarj94c82c3wwajkcmr0wfjx2u3wdejhgqgcwaehxw309aex2mrp0yhxummnw3exzarf9e3k7mgnp0sh5
|
||||||
nak decode nsec1jrmyhtjhgd9yqalps8hf9mayvd58852gtz66m7tqpacjedkp6kxq4dyxsr`,
|
nak decode nsec1jrmyhtjhgd9yqalps8hf9mayvd58852gtz66m7tqpacjedkp6kxq4dyxsr`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "id",
|
Name: "id",
|
||||||
@@ -32,8 +33,8 @@ var decode = &cli.Command{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ArgsUsage: "<npub | nprofile | nip05 | nevent | naddr | nsec>",
|
ArgsUsage: "<npub | nprofile | nip05 | nevent | naddr | nsec>",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
for input := range getStdinLinesOrFirstArgument(c) {
|
for input := range getStdinLinesOrArguments(c.Args()) {
|
||||||
if strings.HasPrefix(input, "nostr:") {
|
if strings.HasPrefix(input, "nostr:") {
|
||||||
input = input[6:]
|
input = input[6:]
|
||||||
}
|
}
|
||||||
@@ -49,29 +50,44 @@ var decode = &cli.Command{
|
|||||||
decodeResult.HexResult.PrivateKey = hex.EncodeToString(b)
|
decodeResult.HexResult.PrivateKey = hex.EncodeToString(b)
|
||||||
decodeResult.HexResult.PublicKey = hex.EncodeToString(b)
|
decodeResult.HexResult.PublicKey = hex.EncodeToString(b)
|
||||||
} else {
|
} else {
|
||||||
lineProcessingError(c, "hex string with invalid number of bytes: %d", len(b))
|
ctx = lineProcessingError(ctx, "hex string with invalid number of bytes: %d", len(b))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if evp := sdk.InputToEventPointer(input); evp != nil {
|
} else if evp := sdk.InputToEventPointer(input); evp != nil {
|
||||||
decodeResult = DecodeResult{EventPointer: evp}
|
decodeResult = DecodeResult{EventPointer: evp}
|
||||||
} else if pp := sdk.InputToProfile(c.Context, input); pp != nil {
|
if c.Bool("id") {
|
||||||
|
stdout(evp.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if pp := sdk.InputToProfile(ctx, input); pp != nil {
|
||||||
decodeResult = DecodeResult{ProfilePointer: pp}
|
decodeResult = DecodeResult{ProfilePointer: pp}
|
||||||
|
if c.Bool("pubkey") {
|
||||||
|
stdout(pp.PublicKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "naddr" {
|
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "naddr" {
|
||||||
ep := value.(nostr.EntityPointer)
|
if ep, ok := value.(nostr.EntityPointer); ok {
|
||||||
decodeResult = DecodeResult{EntityPointer: &ep}
|
decodeResult = DecodeResult{EntityPointer: &ep}
|
||||||
|
} else {
|
||||||
|
ctx = lineProcessingError(ctx, "couldn't decode naddr: %s", err)
|
||||||
|
}
|
||||||
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "nsec" {
|
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "nsec" {
|
||||||
decodeResult.PrivateKey.PrivateKey = value.(string)
|
decodeResult.PrivateKey.PrivateKey = value.(string)
|
||||||
decodeResult.PrivateKey.PublicKey, _ = nostr.GetPublicKey(value.(string))
|
decodeResult.PrivateKey.PublicKey, _ = nostr.GetPublicKey(value.(string))
|
||||||
} else {
|
} else {
|
||||||
lineProcessingError(c, "couldn't decode input '%s': %s", input, err)
|
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Bool("pubkey") || c.Bool("id") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
stdout(decodeResult.JSON())
|
stdout(decodeResult.JSON())
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
133
dvm.go
Normal file
133
dvm.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip90"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dvm = &cli.Command{
|
||||||
|
Name: "dvm",
|
||||||
|
Usage: "deal with nip90 data-vending-machine things (experimental)",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(defaultKeyFlags,
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "relay",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Commands: append([]*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "find DVMs that have announced themselves for a specific kind",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return fmt.Errorf("we don't know how to do this yet")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, (func() []*cli.Command {
|
||||||
|
commands := make([]*cli.Command, len(nip90.Jobs))
|
||||||
|
for i, job := range nip90.Jobs {
|
||||||
|
flags := make([]cli.Flag, 0, 2+len(job.Params))
|
||||||
|
|
||||||
|
if job.InputType != "" {
|
||||||
|
flags = append(flags, &cli.StringSliceFlag{
|
||||||
|
Name: "input",
|
||||||
|
Aliases: []string{"i"},
|
||||||
|
Category: "INPUT",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, param := range job.Params {
|
||||||
|
flags = append(flags, &cli.StringSliceFlag{
|
||||||
|
Name: param,
|
||||||
|
Category: "PARAMETER",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
commands[i] = &cli.Command{
|
||||||
|
Name: strconv.Itoa(job.InputKind),
|
||||||
|
Usage: job.Name,
|
||||||
|
Description: job.Description,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: flags,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relayUrls := c.StringSlice("relay")
|
||||||
|
relays := connectToAllRelays(ctx, c, relayUrls, nil)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
log("failed to connect to any of the given relays.\n")
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, relay := range relays {
|
||||||
|
relay.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: job.InputKind,
|
||||||
|
Tags: make(nostr.Tags, 0, 2+len(job.Params)),
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range c.StringSlice("input") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"i", input, job.InputType})
|
||||||
|
}
|
||||||
|
for _, paramN := range job.Params {
|
||||||
|
for _, paramV := range c.StringSlice(paramN) {
|
||||||
|
tag := nostr.Tag{"param", paramN, "", ""}[0:2]
|
||||||
|
for _, v := range strings.Split(paramV, ";") {
|
||||||
|
tag = append(tag, v)
|
||||||
|
}
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logverbose("%s", evt)
|
||||||
|
|
||||||
|
log("- publishing job request... ")
|
||||||
|
first := true
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relayUrls, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
log("%s: ok", colors.successf(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("\n- waiting for response...\n")
|
||||||
|
for ie := range sys.Pool.SubscribeMany(ctx, relayUrls, nostr.Filter{
|
||||||
|
Kinds: []int{7000, job.OutputKind},
|
||||||
|
Tags: nostr.TagMap{"e": []string{evt.ID}},
|
||||||
|
}) {
|
||||||
|
stdout(ie.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
})()...),
|
||||||
|
}
|
||||||
105
encode.go
105
encode.go
@@ -1,11 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var encode = &cli.Command{
|
var encode = &cli.Command{
|
||||||
@@ -18,20 +19,22 @@ var encode = &cli.Command{
|
|||||||
nak encode nevent <event-id>
|
nak encode nevent <event-id>
|
||||||
nak encode nevent --author <pubkey-hex> --relay <relay-url> --relay <other-relay> <event-id>
|
nak encode nevent --author <pubkey-hex> --relay <relay-url> --relay <other-relay> <event-id>
|
||||||
nak encode nsec <privkey-hex>`,
|
nak encode nsec <privkey-hex>`,
|
||||||
Before: func(c *cli.Context) error {
|
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||||
if c.Args().Len() < 1 {
|
if c.Args().Len() < 1 {
|
||||||
return fmt.Errorf("expected more than 1 argument.")
|
return ctx, fmt.Errorf("expected more than 1 argument.")
|
||||||
}
|
}
|
||||||
return nil
|
return ctx, nil
|
||||||
},
|
},
|
||||||
Subcommands: []*cli.Command{
|
DisableSliceFlagSeparator: true,
|
||||||
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "npub",
|
Name: "npub",
|
||||||
Usage: "encode a hex public key into bech32 'npub' format",
|
Usage: "encode a hex public key into bech32 'npub' format",
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
for target := range getStdinLinesOrFirstArgument(c) {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||||
if ok := nostr.IsValidPublicKey(target); !ok {
|
if ok := nostr.IsValidPublicKey(target); !ok {
|
||||||
lineProcessingError(c, "invalid public key: %s", target)
|
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,17 +45,18 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "nsec",
|
Name: "nsec",
|
||||||
Usage: "encode a hex private key into bech32 'nsec' format",
|
Usage: "encode a hex private key into bech32 'nsec' format",
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
for target := range getStdinLinesOrFirstArgument(c) {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||||
lineProcessingError(c, "invalid private key: %s", target)
|
ctx = lineProcessingError(ctx, "invalid private key: %s", target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,15 +81,16 @@ var encode = &cli.Command{
|
|||||||
Usage: "attach relay hints to nprofile code",
|
Usage: "attach relay hints to nprofile code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
for target := range getStdinLinesOrFirstArgument(c) {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||||
lineProcessingError(c, "invalid public key: %s", target)
|
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
if err := validateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,14 +115,16 @@ var encode = &cli.Command{
|
|||||||
Usage: "attach relay hints to nevent code",
|
Usage: "attach relay hints to nevent code",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "author",
|
Name: "author",
|
||||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
Aliases: []string{"a"},
|
||||||
|
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
for target := range getStdinLinesOrFirstArgument(c) {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||||
lineProcessingError(c, "invalid event id: %s", target)
|
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +136,7 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
if err := validateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +147,13 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "naddr",
|
Name: "naddr",
|
||||||
Usage: "generate codes for NIP-33 parameterized replaceable events",
|
Usage: "generate codes for addressable events",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "identifier",
|
Name: "identifier",
|
||||||
@@ -157,10 +164,10 @@ var encode = &cli.Command{
|
|||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "pubkey",
|
Name: "pubkey",
|
||||||
Usage: "pubkey of the naddr author",
|
Usage: "pubkey of the naddr author",
|
||||||
Aliases: []string{"p"},
|
Aliases: []string{"author", "a", "p"},
|
||||||
Required: true,
|
Required: true,
|
||||||
},
|
},
|
||||||
&cli.Int64Flag{
|
&cli.IntFlag{
|
||||||
Name: "kind",
|
Name: "kind",
|
||||||
Aliases: []string{"k"},
|
Aliases: []string{"k"},
|
||||||
Usage: "kind of referred replaceable event",
|
Usage: "kind of referred replaceable event",
|
||||||
@@ -172,7 +179,8 @@ var encode = &cli.Command{
|
|||||||
Usage: "attach relay hints to naddr code",
|
Usage: "attach relay hints to naddr code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
for d := range getStdinLinesOrBlank() {
|
for d := range getStdinLinesOrBlank() {
|
||||||
pubkey := c.String("pubkey")
|
pubkey := c.String("pubkey")
|
||||||
if ok := nostr.IsValidPublicKey(pubkey); !ok {
|
if ok := nostr.IsValidPublicKey(pubkey); !ok {
|
||||||
@@ -181,51 +189,30 @@ var encode = &cli.Command{
|
|||||||
|
|
||||||
kind := c.Int("kind")
|
kind := c.Int("kind")
|
||||||
if kind < 30000 || kind >= 40000 {
|
if kind < 30000 || kind >= 40000 {
|
||||||
return fmt.Errorf("kind must be between 30000 and 39999, as per NIP-16, got %d", kind)
|
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
if d == "" {
|
if d == "" {
|
||||||
d = c.String("identifier")
|
d = c.String("identifier")
|
||||||
if d == "" {
|
if d == "" {
|
||||||
lineProcessingError(c, "\"d\" tag identifier can't be empty")
|
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
if err := validateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if npub, err := nip19.EncodeEntity(pubkey, kind, d, relays); err == nil {
|
if npub, err := nip19.EncodeEntity(pubkey, int(kind), d, relays); err == nil {
|
||||||
stdout(npub)
|
stdout(npub)
|
||||||
} else {
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "note",
|
|
||||||
Usage: "generate note1 event codes (not recommended)",
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
for target := range getStdinLinesOrFirstArgument(c) {
|
|
||||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
|
||||||
lineProcessingError(c, "invalid event id: %s", target)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if note, err := nip19.EncodeNote(target); err == nil {
|
|
||||||
stdout(note)
|
|
||||||
} else {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
140
encrypt_decrypt.go
Normal file
140
encrypt_decrypt.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip04"
|
||||||
|
)
|
||||||
|
|
||||||
|
var encrypt = &cli.Command{
|
||||||
|
Name: "encrypt",
|
||||||
|
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
|
||||||
|
ArgsUsage: "[plaintext string]",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(
|
||||||
|
defaultKeyFlags,
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "recipient-pubkey",
|
||||||
|
Aliases: []string{"p", "tgt", "target", "pubkey"},
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "nip04",
|
||||||
|
Usage: "use nip04 encryption instead of nip44",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
target := c.String("recipient-pubkey")
|
||||||
|
if !nostr.IsValidPublicKey(target) {
|
||||||
|
return fmt.Errorf("target %s is not a valid public key", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := c.Args().First()
|
||||||
|
|
||||||
|
if c.Bool("nip04") {
|
||||||
|
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bunker != nil {
|
||||||
|
ciphertext, err := bunker.NIP04Encrypt(ctx, target, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stdout(ciphertext)
|
||||||
|
} else {
|
||||||
|
ss, err := nip04.ComputeSharedSecret(target, sec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := nip04.Encrypt(plaintext, ss)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt as nip04: %w", err)
|
||||||
|
}
|
||||||
|
stdout(ciphertext)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := kr.Encrypt(ctx, plaintext, target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
stdout(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var decrypt = &cli.Command{
|
||||||
|
Name: "decrypt",
|
||||||
|
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
|
||||||
|
ArgsUsage: "[ciphertext base64]",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(
|
||||||
|
defaultKeyFlags,
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sender-pubkey",
|
||||||
|
Aliases: []string{"p", "src", "source", "pubkey"},
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "nip04",
|
||||||
|
Usage: "use nip04 encryption instead of nip44",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
source := c.String("sender-pubkey")
|
||||||
|
if !nostr.IsValidPublicKey(source) {
|
||||||
|
return fmt.Errorf("source %s is not a valid public key", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := c.Args().First()
|
||||||
|
|
||||||
|
if c.Bool("nip04") {
|
||||||
|
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bunker != nil {
|
||||||
|
plaintext, err := bunker.NIP04Decrypt(ctx, source, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stdout(plaintext)
|
||||||
|
} else {
|
||||||
|
ss, err := nip04.ComputeSharedSecret(source, sec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
|
||||||
|
}
|
||||||
|
plaintext, err := nip04.Decrypt(ciphertext, ss)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt as nip04: %w", err)
|
||||||
|
}
|
||||||
|
stdout(plaintext)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := kr.Decrypt(ctx, ciphertext, source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
stdout(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
351
event.go
351
event.go
@@ -2,22 +2,25 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/mailru/easyjson"
|
"github.com/mailru/easyjson"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip13"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
"github.com/nbd-wtf/go-nostr/nson"
|
"github.com/urfave/cli/v3"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
|
const (
|
||||||
|
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
|
||||||
|
CATEGORY_SIGNER = "SIGNER OPTIONS"
|
||||||
|
CATEGORY_EXTRAS = "EXTRAS"
|
||||||
|
)
|
||||||
|
|
||||||
var event = &cli.Command{
|
var event = &cli.Command{
|
||||||
Name: "event",
|
Name: "event",
|
||||||
@@ -33,34 +36,55 @@ if an event -- or a partial event -- is given on stdin, the flags can be used to
|
|||||||
example:
|
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 '{"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'`,
|
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
|
||||||
Flags: []cli.Flag{
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(defaultKeyFlags,
|
||||||
|
// ~ these args are only for the convoluted musig2 signing process
|
||||||
|
// they will be generally copy-shared-pasted across some manual coordination method between participants
|
||||||
|
&cli.UintFlag{
|
||||||
|
Name: "musig",
|
||||||
|
Usage: "number of signers to use for musig2",
|
||||||
|
Value: 1,
|
||||||
|
DefaultText: "1 -- i.e. do not use musig2 at all",
|
||||||
|
Category: CATEGORY_SIGNER,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "musig-pubkey",
|
||||||
|
Hidden: true,
|
||||||
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "sec",
|
Name: "musig-nonce-secret",
|
||||||
Usage: "secret key to sign the event, as hex or nsec",
|
Hidden: true,
|
||||||
DefaultText: "the key '1'",
|
},
|
||||||
Value: "0000000000000000000000000000000000000000000000000000000000000001",
|
&cli.StringSliceFlag{
|
||||||
|
Name: "musig-nonce",
|
||||||
|
Hidden: true,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "musig-partial",
|
||||||
|
Hidden: true,
|
||||||
|
},
|
||||||
|
// ~~~
|
||||||
|
&cli.UintFlag{
|
||||||
|
Name: "pow",
|
||||||
|
Usage: "nip13 difficulty to target when doing hash work on the event id",
|
||||||
|
Category: CATEGORY_EXTRAS,
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "prompt-sec",
|
Name: "envelope",
|
||||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
|
||||||
|
Category: CATEGORY_EXTRAS,
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "envelope",
|
Name: "auth",
|
||||||
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
|
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||||
|
Category: CATEGORY_EXTRAS,
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "auth",
|
Name: "nevent",
|
||||||
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
Usage: "print the nevent code (to stderr) after the event is published",
|
||||||
|
Category: CATEGORY_EXTRAS,
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.UintFlag{
|
||||||
Name: "nevent",
|
|
||||||
Usage: "print the nevent code (to stderr) after the event is published",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "nson",
|
|
||||||
Usage: "encode the event using NSON",
|
|
||||||
},
|
|
||||||
&cli.IntFlag{
|
|
||||||
Name: "kind",
|
Name: "kind",
|
||||||
Aliases: []string{"k"},
|
Aliases: []string{"k"},
|
||||||
Usage: "event kind",
|
Usage: "event kind",
|
||||||
@@ -71,7 +95,7 @@ example:
|
|||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "content",
|
Name: "content",
|
||||||
Aliases: []string{"c"},
|
Aliases: []string{"c"},
|
||||||
Usage: "event content",
|
Usage: "event content (if it starts with an '@' will read from a file)",
|
||||||
DefaultText: "hello from the nostr army knife",
|
DefaultText: "hello from the nostr army knife",
|
||||||
Value: "",
|
Value: "",
|
||||||
Category: CATEGORY_EVENT_FIELDS,
|
Category: CATEGORY_EVENT_FIELDS,
|
||||||
@@ -79,7 +103,7 @@ example:
|
|||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "tag",
|
Name: "tag",
|
||||||
Aliases: []string{"t"},
|
Aliases: []string{"t"},
|
||||||
Usage: "sets a tag field on the event, takes a value like -t e=<id>",
|
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
|
||||||
Category: CATEGORY_EVENT_FIELDS,
|
Category: CATEGORY_EVENT_FIELDS,
|
||||||
},
|
},
|
||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
@@ -97,76 +121,92 @@ example:
|
|||||||
Usage: "shortcut for --tag d=<value>",
|
Usage: "shortcut for --tag d=<value>",
|
||||||
Category: CATEGORY_EVENT_FIELDS,
|
Category: CATEGORY_EVENT_FIELDS,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&NaturalTimeFlag{
|
||||||
Name: "created-at",
|
Name: "created-at",
|
||||||
Aliases: []string{"time", "ts"},
|
Aliases: []string{"time", "ts"},
|
||||||
Usage: "unix timestamp value for the created_at field",
|
Usage: "unix timestamp value for the created_at field",
|
||||||
DefaultText: "now",
|
DefaultText: "now",
|
||||||
Value: "",
|
Value: nostr.Now(),
|
||||||
Category: CATEGORY_EVENT_FIELDS,
|
Category: CATEGORY_EVENT_FIELDS,
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
var relays []*nostr.Relay
|
var relays []*nostr.Relay
|
||||||
|
|
||||||
|
// these are defaults, they will be replaced if we use the magic dynamic thing
|
||||||
|
logthis := func(relayUrl string, s string, args ...any) { log(s, args...) }
|
||||||
|
colorizethis := func(relayUrl string, colorize func(string, ...any) string) {}
|
||||||
|
|
||||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||||
_, relays = connectToAllRelays(c.Context, relayUrls)
|
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||||
|
nostr.WithAuthHandler(func(ctx context.Context, authEvent nostr.RelayEvent) error {
|
||||||
|
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, relay := range relays {
|
for _, relay := range relays {
|
||||||
relay.Close()
|
relay.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// gather the secret key
|
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||||
sec, err := gatherSecretKeyFromArguments(c)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
doAuth := c.Bool("auth")
|
doAuth := c.Bool("auth")
|
||||||
|
|
||||||
// then process input and generate events
|
// then process input and generate events:
|
||||||
for stdinEvent := range getStdinLinesOrBlank() {
|
|
||||||
evt := nostr.Event{
|
|
||||||
Tags: make(nostr.Tags, 0, 3),
|
|
||||||
}
|
|
||||||
|
|
||||||
kindWasSupplied := false
|
// will reuse this
|
||||||
|
var evt nostr.Event
|
||||||
|
|
||||||
|
// this is called when we have a valid json from stdin
|
||||||
|
handleEvent := func(stdinEvent string) error {
|
||||||
|
evt.Content = ""
|
||||||
|
|
||||||
|
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
|
||||||
mustRehashAndResign := false
|
mustRehashAndResign := false
|
||||||
|
|
||||||
if stdinEvent != "" {
|
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||||
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
return fmt.Errorf("invalid event received from stdin: %s", err)
|
||||||
lineProcessingError(c, "invalid event received from stdin: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if kind := c.Int("kind"); slices.Contains(c.FlagNames(), "kind") {
|
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
|
||||||
evt.Kind = kind
|
evt.Kind = int(kind)
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
} else if !kindWasSupplied {
|
} else if !kindWasSupplied {
|
||||||
evt.Kind = 1
|
evt.Kind = 1
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if content := c.String("content"); content != "" {
|
if c.IsSet("content") {
|
||||||
evt.Content = content
|
content := c.String("content")
|
||||||
|
if strings.HasPrefix(content, "@") {
|
||||||
|
filedata, err := os.ReadFile(content[1:])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file '%s' for content: %w", content[1:], err)
|
||||||
|
}
|
||||||
|
evt.Content = string(filedata)
|
||||||
|
} else {
|
||||||
|
evt.Content = content
|
||||||
|
}
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
} else if evt.Content == "" && evt.Kind == 1 {
|
} else if evt.Content == "" && evt.Kind == 1 {
|
||||||
evt.Content = "hello from the nostr army knife"
|
evt.Content = "hello from the nostr army knife"
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
}
|
}
|
||||||
|
|
||||||
tags := make(nostr.Tags, 0, 5)
|
tagFlags := c.StringSlice("tag")
|
||||||
for _, tagFlag := range c.StringSlice("tag") {
|
tags := make(nostr.Tags, 0, len(tagFlags)+2)
|
||||||
|
for _, tagFlag := range tagFlags {
|
||||||
// tags are in the format key=value
|
// tags are in the format key=value
|
||||||
tagName, tagValue, found := strings.Cut(tagFlag, "=")
|
tagName, tagValue, found := strings.Cut(tagFlag, "=")
|
||||||
tag := []string{tagName}
|
tag := []string{tagName}
|
||||||
@@ -174,22 +214,24 @@ example:
|
|||||||
// tags may also contain extra elements separated with a ";"
|
// tags may also contain extra elements separated with a ";"
|
||||||
tagValues := strings.Split(tagValue, ";")
|
tagValues := strings.Split(tagValue, ";")
|
||||||
tag = append(tag, tagValues...)
|
tag = append(tag, tagValues...)
|
||||||
// ~
|
|
||||||
tags = tags.AppendUnique(tag)
|
|
||||||
}
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, etag := range c.StringSlice("e") {
|
for _, etag := range c.StringSlice("e") {
|
||||||
tags = tags.AppendUnique([]string{"e", etag})
|
if tags.FindWithValue("e", etag) == nil {
|
||||||
mustRehashAndResign = true
|
tags = append(tags, nostr.Tag{"e", etag})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, ptag := range c.StringSlice("p") {
|
for _, ptag := range c.StringSlice("p") {
|
||||||
tags = tags.AppendUnique([]string{"p", ptag})
|
if tags.FindWithValue("p", ptag) == nil {
|
||||||
mustRehashAndResign = true
|
tags = append(tags, nostr.Tag{"p", ptag})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, dtag := range c.StringSlice("d") {
|
for _, dtag := range c.StringSlice("d") {
|
||||||
tags = tags.AppendUnique([]string{"d", dtag})
|
if tags.FindWithValue("d", dtag) == nil {
|
||||||
mustRehashAndResign = true
|
tags = append(tags, nostr.Tag{"d", dtag})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(tags) > 0 {
|
if len(tags) > 0 {
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
@@ -198,24 +240,58 @@ example:
|
|||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if createdAt := c.String("created-at"); createdAt != "" {
|
if c.IsSet("created-at") {
|
||||||
ts := time.Now()
|
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||||
if createdAt != "now" {
|
|
||||||
if v, err := strconv.ParseInt(createdAt, 10, 64); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse timestamp '%s': %w", createdAt, err)
|
|
||||||
} else {
|
|
||||||
ts = time.Unix(v, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
evt.CreatedAt = nostr.Timestamp(ts.Unix())
|
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
} else if evt.CreatedAt == 0 {
|
} else if evt.CreatedAt == 0 {
|
||||||
evt.CreatedAt = nostr.Now()
|
evt.CreatedAt = nostr.Now()
|
||||||
mustRehashAndResign = true
|
mustRehashAndResign = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
|
||||||
|
mustRehashAndResign = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if difficulty := c.Uint("pow"); difficulty > 0 {
|
||||||
|
// before doing pow we need the pubkey
|
||||||
|
if numSigners := c.Uint("musig"); numSigners > 1 {
|
||||||
|
pubkeys := c.StringSlice("musig-pubkey")
|
||||||
|
if int(numSigners) != len(pubkeys) {
|
||||||
|
return fmt.Errorf("when doing a pow with musig we must know all signer pubkeys upfront")
|
||||||
|
}
|
||||||
|
evt.PubKey, err = getMusigAggregatedKey(ctx, pubkeys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to generate work with this difficulty -- runs forever
|
||||||
|
nonceTag, _ := nip13.DoWork(ctx, evt, int(difficulty))
|
||||||
|
evt.Tags = append(evt.Tags, nonceTag)
|
||||||
|
|
||||||
|
mustRehashAndResign = true
|
||||||
|
}
|
||||||
|
|
||||||
if evt.Sig == "" || mustRehashAndResign {
|
if evt.Sig == "" || mustRehashAndResign {
|
||||||
if err := evt.Sign(sec); err != nil {
|
if numSigners := c.Uint("musig"); numSigners > 1 {
|
||||||
|
// must do musig
|
||||||
|
pubkeys := c.StringSlice("musig-pubkey")
|
||||||
|
secNonce := c.String("musig-nonce-secret")
|
||||||
|
pubNonces := c.StringSlice("musig-nonce")
|
||||||
|
partialSigs := c.StringSlice("musig-partial")
|
||||||
|
signed, err := performMusig(ctx,
|
||||||
|
sec, &evt, int(numSigners), pubkeys, pubNonces, secNonce, partialSigs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("musig error: %w", err)
|
||||||
|
}
|
||||||
|
if !signed {
|
||||||
|
// we haven't finished signing the event, so the users still have to do more steps
|
||||||
|
// instructions for what to do should have been printed by the performMusig() function
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
return fmt.Errorf("error signing with provided key: %w", err)
|
return fmt.Errorf("error signing with provided key: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,8 +301,6 @@ example:
|
|||||||
if c.Bool("envelope") {
|
if c.Bool("envelope") {
|
||||||
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
||||||
result = string(j)
|
result = string(j)
|
||||||
} else if c.Bool("nson") {
|
|
||||||
result, _ = nson.Marshal(&evt)
|
|
||||||
} else {
|
} else {
|
||||||
j, _ := easyjson.Marshal(&evt)
|
j, _ := easyjson.Marshal(&evt)
|
||||||
result = string(j)
|
result = string(j)
|
||||||
@@ -237,43 +311,120 @@ example:
|
|||||||
successRelays := make([]string, 0, len(relays))
|
successRelays := make([]string, 0, len(relays))
|
||||||
if len(relays) > 0 {
|
if len(relays) > 0 {
|
||||||
os.Stdout.Sync()
|
os.Stdout.Sync()
|
||||||
for _, relay := range relays {
|
|
||||||
publish:
|
if supportsDynamicMultilineMagic() {
|
||||||
log("publishing to %s... ", relay.URL)
|
// overcomplicated multiline rendering magic
|
||||||
ctx, cancel := context.WithTimeout(c.Context, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := relay.Publish(ctx, evt)
|
urls := make([]string, len(relays))
|
||||||
if err == nil {
|
lines := make([][][]byte, len(urls))
|
||||||
// published fine
|
flush := func() {
|
||||||
log("success.\n")
|
for _, line := range lines {
|
||||||
successRelays = append(successRelays, relay.URL)
|
for _, part := range line {
|
||||||
continue // continue to next relay
|
os.Stderr.Write(part)
|
||||||
}
|
}
|
||||||
|
os.Stderr.Write([]byte{'\n'})
|
||||||
// error publishing
|
|
||||||
if strings.HasPrefix(err.Error(), "msg: auth-required:") && sec != "" && doAuth {
|
|
||||||
// if the relay is requesting auth and we can auth, let's do it
|
|
||||||
pk, _ := nostr.GetPublicKey(sec)
|
|
||||||
log("performing auth as %s... ", pk)
|
|
||||||
if err := relay.Auth(c.Context, func(evt *nostr.Event) error { return evt.Sign(sec) }); err == nil {
|
|
||||||
// try to publish again, but this time don't try to auth again
|
|
||||||
doAuth = false
|
|
||||||
goto publish
|
|
||||||
} else {
|
|
||||||
log("auth error: %s. ", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log("failed: %s\n", err)
|
render := func() {
|
||||||
|
clearLines(len(lines))
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
|
||||||
|
logthis = func(relayUrl, s string, args ...any) {
|
||||||
|
idx := slices.Index(urls, relayUrl)
|
||||||
|
lines[idx] = append(lines[idx], []byte(fmt.Sprintf(s, args...)))
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
colorizethis = func(relayUrl string, colorize func(string, ...any) string) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(relayUrl, "wss://")
|
||||||
|
idx := slices.Index(urls, relayUrl)
|
||||||
|
lines[idx][0] = []byte(fmt.Sprintf("publishing to %s... ", colorize(cleanUrl)))
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, relay := range relays {
|
||||||
|
urls[i] = relay.URL
|
||||||
|
lines[i] = make([][]byte, 1, 3)
|
||||||
|
colorizethis(relay.URL, color.CyanString)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, urls, evt) {
|
||||||
|
if res.Error == nil {
|
||||||
|
colorizethis(res.RelayURL, colors.successf)
|
||||||
|
logthis(res.RelayURL, "success.")
|
||||||
|
successRelays = append(successRelays, res.RelayURL)
|
||||||
|
} else {
|
||||||
|
colorizethis(res.RelayURL, colors.errorf)
|
||||||
|
|
||||||
|
// in this case it's likely that the lowest-level error is the one that will be more helpful
|
||||||
|
low := unwrapAll(res.Error)
|
||||||
|
|
||||||
|
// hack for some messages such as from relay.westernbtc.com
|
||||||
|
msg := strings.ReplaceAll(low.Error(), evt.PubKey, "author")
|
||||||
|
|
||||||
|
// do not allow the message to overflow the term window
|
||||||
|
msg = clampMessage(msg, 20+len(res.RelayURL))
|
||||||
|
|
||||||
|
logthis(res.RelayURL, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// normal dumb flow
|
||||||
|
for _, relay := range relays {
|
||||||
|
publish:
|
||||||
|
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
|
||||||
|
log("publishing to %s... ", color.CyanString(cleanUrl))
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := relay.Publish(ctx, evt)
|
||||||
|
if err == nil {
|
||||||
|
// published fine
|
||||||
|
log("success.\n")
|
||||||
|
successRelays = append(successRelays, relay.URL)
|
||||||
|
continue // continue to next relay
|
||||||
|
}
|
||||||
|
|
||||||
|
// error publishing
|
||||||
|
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
|
||||||
|
// if the relay is requesting auth and we can auth, let's do it
|
||||||
|
pk, _ := kr.GetPublicKey(ctx)
|
||||||
|
npub, _ := nip19.EncodePublicKey(pk)
|
||||||
|
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
|
||||||
|
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
|
||||||
|
return kr.SignEvent(ctx, authEvent)
|
||||||
|
}); err == nil {
|
||||||
|
// try to publish again, but this time don't try to auth again
|
||||||
|
doAuth = false
|
||||||
|
goto publish
|
||||||
|
} else {
|
||||||
|
log("auth error: %s. ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("failed: %s\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(successRelays) > 0 && c.Bool("nevent") {
|
if len(successRelays) > 0 && c.Bool("nevent") {
|
||||||
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
|
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
|
||||||
log(nevent + "\n")
|
log(nevent + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
for stdinEvent := range getJsonsOrBlank() {
|
||||||
|
if err := handleEvent(stdinEvent); err != nil {
|
||||||
|
ctx = lineProcessingError(ctx, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
129
example_test.go
129
example_test.go
@@ -1,31 +1,138 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
|
||||||
|
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
|
||||||
|
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
func ExampleEventBasic() {
|
func ExampleEventBasic() {
|
||||||
app.Run([]string{"nak", "event", "--ts", "1699485669"})
|
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669"})
|
||||||
// Output:
|
// Output:
|
||||||
// {"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
|
// {"kind":1,"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (for some reason there can only be one test dealing with stdin in the suite otherwise it halts)
|
||||||
|
// func ExampleEventParsingFromStdin() {
|
||||||
|
// prevStdin := os.Stdin
|
||||||
|
// defer func() { os.Stdin = prevStdin }()
|
||||||
|
// r, w, _ := os.Pipe()
|
||||||
|
// os.Stdin = r
|
||||||
|
// w.WriteString("{\"content\":\"hello world\"}\n{\"content\":\"hello sun\"}\n")
|
||||||
|
// app.Run(ctx, []string{"nak", "event", "-t", "t=spam", "--ts", "1699485669"})
|
||||||
|
// // Output:
|
||||||
|
// // {"id":"bda134f9077c11973afe6aa5a1cc6f5bcea01c40d318b8f91dcb8e50507cfa52","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello world","sig":"7552454bb8e7944230142634e3e34ac7468bad9b21ed6909da572c611018dff1d14d0792e98b5806f6330edc51e09efa6d0b66a9694dc34606c70f4e580e7493"}
|
||||||
|
// // {"id":"879c36ec73acca288825b53585389581d3836e7f0fe4d46e5eba237ca56d6af5","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello sun","sig":"6c7e6b13ebdf931d26acfdd00bec2ec1140ddaf8d1ed61453543a14e729a460fe36c40c488ccb194a0e1ab9511cb6c36741485f501bdb93c39ca4c51bc59cbd4"}
|
||||||
|
// }
|
||||||
|
|
||||||
func ExampleEventComplex() {
|
func ExampleEventComplex() {
|
||||||
app.Run([]string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def?name=foobar;nothing"})
|
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def?name=foobar;nothing"})
|
||||||
// Output:
|
// Output:
|
||||||
// {"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"kind":11,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
|
// {"kind":11,"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleEncode() {
|
||||||
|
app.Run(ctx, []string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
||||||
|
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
||||||
|
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822", "a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"})
|
||||||
|
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
|
||||||
|
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
||||||
|
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
||||||
|
// nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleDecode() {
|
||||||
|
app.Run(ctx, []string{"nak", "decode", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
|
||||||
|
// Output:
|
||||||
|
// {
|
||||||
|
// "pubkey": "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e",
|
||||||
|
// "kind": 31923,
|
||||||
|
// "identifier": "4cd6cfe7",
|
||||||
|
// "relays": [
|
||||||
|
// "wss://nos.lol"
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// {
|
||||||
|
// "id": "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5",
|
||||||
|
// "relays": [
|
||||||
|
// "wss://pyramid.fiatjaf.com/",
|
||||||
|
// "wss://relay.westernbtc.com/",
|
||||||
|
// "wss://relay.snort.social/",
|
||||||
|
// "wss://atlas.nostr.land/"
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleDecodePubkey() {
|
||||||
|
app.Run(ctx, []string{"nak", "decode", "-p", "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d", "npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd"})
|
||||||
|
// Output:
|
||||||
|
// 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||||
|
// c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleDecodeEventId() {
|
||||||
|
app.Run(ctx, []string{"nak", "decode", "-e", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8", "nevent1qqswh48lurxs8u0pll9qj2rzctvjncwhstpzlstq59rdtzlty79awns5hl5uf"})
|
||||||
|
// Output:
|
||||||
|
// 3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5
|
||||||
|
// ebd4ffe0cd03f1e1ffca092862c2d929e1d782c22fc160a146d58beb278bd74e
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleReq() {
|
func ExampleReq() {
|
||||||
app.Run([]string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
|
app.Run(ctx, []string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
|
||||||
// Output:
|
// Output:
|
||||||
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
|
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleEncodeNpub() {
|
func ExampleMultipleFetch() {
|
||||||
app.Run([]string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
app.Run(ctx, []string{"nak", "fetch", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
|
||||||
// Output:
|
// Output:
|
||||||
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
|
// {"kind":31923,"id":"9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05","pubkey":"5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","created_at":1707764605,"tags":[["d","4cd6cfe7"],["name","Nostr PHX Presents Culture Shock"],["description","Nostr PHX presents Culture Shock the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, food, drinks, conversation, vendors and best of all, a live convert which will stream globally for the world to zap. "],["start","1708185600"],["end","1708228800"],["start_tzid","America/Phoenix"],["p","5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","","host"],["location","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["address","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["g","9tbq1rzn"],["image","https://flockstr.s3.amazonaws.com/event/15vSaiscDhVH1KBXhA0i8"],["about","Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. "],["calendar","31924:5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e:1f238c94"]],"content":"Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. ","sig":"f676629d1414d96b464644de6babde0c96bd21ef9b41ba69ad886a1d13a942b855b715b22ccf38bc07fead18d3bdeee82d9e3825cf6f003fb5ff1766d95c70a0"}
|
||||||
|
// {"kind":1,"id":"3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1710759386,"tags":[],"content":"Nostr was coopted by our the corporate overlords. It is now featured in https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml.","sig":"faaec167cca4de50b562b7702e8854e2023f0ccd5f36d1b95b6eac20d352206342d6987e9516d283068c768e94dbe8858e2990c3e05405e707fb6fb771ef92f9"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleEncodeNprofile() {
|
func ExampleKeyPublic() {
|
||||||
app.Run([]string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
app.Run(ctx, []string{"nak", "key", "public", "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"})
|
||||||
// Output:
|
// Output:
|
||||||
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
// 70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8
|
||||||
|
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleKeyDecrypt() {
|
||||||
|
app.Run(ctx, []string{"nak", "key", "decrypt", "ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft", "banana"})
|
||||||
|
// Output:
|
||||||
|
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleReqIdFromRelay() {
|
||||||
|
app.Run(ctx, []string{"nak", "req", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1", "nos.lol"})
|
||||||
|
// Output:
|
||||||
|
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleReqWithFlagsAfter1() {
|
||||||
|
app.Run(ctx, []string{"nak", "req", "nos.lol", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1"})
|
||||||
|
// Output:
|
||||||
|
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleReqWithFlagsAfter2() {
|
||||||
|
app.Run(ctx, []string{"nak", "req", "-e", "893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1", "nostr.mom", "--author", "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", "--limit", "1", "-k", "7"})
|
||||||
|
// Output:
|
||||||
|
// {"kind":7,"id":"9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857","pubkey":"2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6","created_at":1720987327,"tags":[["e","893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1"],["p","1e978baae414eee990dba992871549ad4a099b9d6f7e71c8059b254ea024dddc"],["k","1"]],"content":"❤️","sig":"7eddd112c642ecdb031330dadc021790642b3c10ecc64158ba3ae63edd798b26afb9b5a3bba72835ce171719a724de1472f65c9b3339b6bead0ce2846f93dfc9"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleReqWithFlagsAfter3() {
|
||||||
|
app.Run(ctx, []string{"nak", "req", "--limit", "1", "pyramid.fiatjaf.com", "-a", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-qp", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-e", "9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188"})
|
||||||
|
// Output:
|
||||||
|
// {"kind":1,"id":"101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67","pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","created_at":1720987305,"tags":[["e","ceacdc29fa7a0b51640b30d2424e188215460617db5ba5bb52d3fbf0094eebb3","","root"],["e","9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188","","reply"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","6b96c3eb36c6cd457d906bbaafe7b36cacfb8bcc4ab235be6eab3b71c6669251"]],"content":"Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.","sig":"b098820b4a5635865cada9f9a5813be2bc6dd7180e16e590cf30e07916d8ed6ed98ab38b64f3bfba12d88d37335f229f7ef8c084bc48132e936c664a54d3e650"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleNaturalTimestamps() {
|
||||||
|
app.Run(ctx, []string{"nak", "event", "-t", "plu=pla", "-e", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "--ts", "May 19 2018 03:37:19", "-c", "nn"})
|
||||||
|
// Output:
|
||||||
|
// {"kind":0,"id":"b10da0095f96aa2accd99fa3d93bf29a76f51d2594cf5a0a52f8e961aecd0b67","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1526711839,"tags":[["plu","pla"],["e","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"]],"content":"nn","sig":"988442c97064a041ba5e2bfbd64e84d3f819b2169e865511d9d53e74667949ff165325942acaa2ca233c8b529adedf12cf44088cf04081b56d098c5f4d52dd8f"}
|
||||||
}
|
}
|
||||||
|
|||||||
137
fetch.go
137
fetch.go
@@ -1,101 +1,124 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip05"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
sdk "github.com/nbd-wtf/nostr-sdk"
|
"github.com/nbd-wtf/go-nostr/sdk/hints"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var fetch = &cli.Command{
|
var fetch = &cli.Command{
|
||||||
Name: "fetch",
|
Name: "fetch",
|
||||||
Usage: "fetches events related to the given nip19 code from the included relay hints",
|
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's outbox relays.",
|
||||||
Description: `example usage:
|
Description: `example usage:
|
||||||
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
|
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
|
||||||
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
|
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
|
||||||
Flags: []cli.Flag{
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: append(reqFilterFlags,
|
||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "relay",
|
Name: "relay",
|
||||||
Aliases: []string{"r"},
|
Aliases: []string{"r"},
|
||||||
Usage: "also use these relays to fetch from",
|
Usage: "also use these relays to fetch from",
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
ArgsUsage: "[nip19code]",
|
ArgsUsage: "[nip05_or_nip19_code]",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
pool := nostr.NewSimplePool(c.Context)
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
|
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
|
||||||
relay.Close()
|
relay.Close()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for code := range getStdinLinesOrFirstArgument(c) {
|
for code := range getStdinLinesOrArguments(c.Args()) {
|
||||||
filter := nostr.Filter{}
|
filter := nostr.Filter{}
|
||||||
|
|
||||||
prefix, value, err := nip19.Decode(code)
|
|
||||||
if err != nil {
|
|
||||||
lineProcessingError(c, "failed to decode: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
|
||||||
if err := validateRelayURLs(relays); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var authorHint string
|
var authorHint string
|
||||||
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
switch prefix {
|
if nip05.IsValidIdentifier(code) {
|
||||||
case "nevent":
|
pp, err := nip05.QueryIdentifier(ctx, code)
|
||||||
v := value.(nostr.EventPointer)
|
if err != nil {
|
||||||
filter.IDs = append(filter.IDs, v.ID)
|
ctx = lineProcessingError(ctx, "failed to fetch nip05: %s", err)
|
||||||
if v.Author != "" {
|
continue
|
||||||
authorHint = v.Author
|
}
|
||||||
|
authorHint = pp.PublicKey
|
||||||
|
relays = append(relays, pp.Relays...)
|
||||||
|
filter.Authors = append(filter.Authors, pp.PublicKey)
|
||||||
|
} else {
|
||||||
|
prefix, value, err := nip19.Decode(code)
|
||||||
|
if err != nil {
|
||||||
|
ctx = lineProcessingError(ctx, "failed to decode: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prefix {
|
||||||
|
case "nevent":
|
||||||
|
v := value.(nostr.EventPointer)
|
||||||
|
filter.IDs = append(filter.IDs, v.ID)
|
||||||
|
if v.Author != "" {
|
||||||
|
authorHint = v.Author
|
||||||
|
}
|
||||||
|
relays = append(relays, v.Relays...)
|
||||||
|
case "note":
|
||||||
|
filter.IDs = append(filter.IDs, value.(string))
|
||||||
|
case "naddr":
|
||||||
|
v := value.(nostr.EntityPointer)
|
||||||
|
filter.Kinds = []int{v.Kind}
|
||||||
|
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
|
||||||
|
filter.Authors = append(filter.Authors, v.PublicKey)
|
||||||
|
authorHint = v.PublicKey
|
||||||
|
relays = append(relays, v.Relays...)
|
||||||
|
case "nprofile":
|
||||||
|
v := value.(nostr.ProfilePointer)
|
||||||
|
filter.Authors = append(filter.Authors, v.PublicKey)
|
||||||
|
authorHint = v.PublicKey
|
||||||
|
relays = append(relays, v.Relays...)
|
||||||
|
case "npub":
|
||||||
|
v := value.(string)
|
||||||
|
filter.Authors = append(filter.Authors, v)
|
||||||
|
authorHint = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected prefix %s", prefix)
|
||||||
}
|
}
|
||||||
relays = append(relays, v.Relays...)
|
|
||||||
case "naddr":
|
|
||||||
v := value.(nostr.EntityPointer)
|
|
||||||
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
|
|
||||||
filter.Kinds = append(filter.Kinds, v.Kind)
|
|
||||||
filter.Authors = append(filter.Authors, v.PublicKey)
|
|
||||||
authorHint = v.PublicKey
|
|
||||||
relays = append(relays, v.Relays...)
|
|
||||||
case "nprofile":
|
|
||||||
v := value.(nostr.ProfilePointer)
|
|
||||||
filter.Authors = append(filter.Authors, v.PublicKey)
|
|
||||||
filter.Kinds = append(filter.Kinds, 0)
|
|
||||||
authorHint = v.PublicKey
|
|
||||||
relays = append(relays, v.Relays...)
|
|
||||||
case "npub":
|
|
||||||
v := value.(string)
|
|
||||||
filter.Authors = append(filter.Authors, v)
|
|
||||||
filter.Kinds = append(filter.Kinds, 0)
|
|
||||||
authorHint = v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorHint != "" {
|
if authorHint != "" {
|
||||||
relayList := sdk.FetchRelaysForPubkey(c.Context, pool, authorHint,
|
for _, url := range relays {
|
||||||
"wss://purplepag.es", "wss://relay.damus.io", "wss://relay.noswhere.com",
|
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
|
||||||
"wss://nos.lol", "wss://public.relaying.io", "wss://relay.nostr.band")
|
}
|
||||||
for _, relayListItem := range relayList {
|
|
||||||
if relayListItem.Outbox {
|
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
|
||||||
relays = append(relays, relayListItem.URL)
|
relays = append(relays, url)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.Authors) > 0 && len(filter.Kinds) == 0 {
|
||||||
|
filter.Kinds = append(filter.Kinds, 0)
|
||||||
|
}
|
||||||
|
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
lineProcessingError(c, "no relay hints found")
|
ctx = lineProcessingError(ctx, "no relay hints found")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for ie := range pool.SubManyEose(c.Context, relays, nostr.Filters{filter}) {
|
for ie := range sys.Pool.FetchMany(ctx, relays, filter) {
|
||||||
stdout(ie.Event)
|
stdout(ie.Event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
95
flags.go
Normal file
95
flags.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/markusmobius/go-dateparser"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||||
|
|
||||||
|
// wrap to satisfy golang's flag interface.
|
||||||
|
type naturalTimeValue struct {
|
||||||
|
timestamp *nostr.Timestamp
|
||||||
|
hasBeenSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ cli.ValueCreator[nostr.Timestamp, struct{}] = naturalTimeValue{}
|
||||||
|
|
||||||
|
// Below functions are to satisfy the ValueCreator interface
|
||||||
|
|
||||||
|
func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c struct{}) cli.Value {
|
||||||
|
*p = val
|
||||||
|
return &naturalTimeValue{
|
||||||
|
timestamp: p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t naturalTimeValue) ToString(b nostr.Timestamp) string {
|
||||||
|
ts := b.Time()
|
||||||
|
|
||||||
|
if ts.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp constructor(for internal testing only)
|
||||||
|
func newTimestamp(timestamp nostr.Timestamp) *naturalTimeValue {
|
||||||
|
return &naturalTimeValue{timestamp: ×tamp}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below functions are to satisfy the flag.Value interface
|
||||||
|
|
||||||
|
// Parses the string value to timestamp
|
||||||
|
func (t *naturalTimeValue) Set(value string) error {
|
||||||
|
var ts time.Time
|
||||||
|
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
// when the input is a raw number, treat it as an exact timestamp
|
||||||
|
ts = time.Unix(n, 0)
|
||||||
|
} else if errors.Is(err, strconv.ErrRange) {
|
||||||
|
// this means a huge number, so we should fail
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
// otherwise try to parse it as a human date string in natural language
|
||||||
|
date, err := dateparser.Parse(&dateparser.Configuration{
|
||||||
|
DefaultTimezone: time.Local,
|
||||||
|
CurrentTime: time.Now(),
|
||||||
|
}, value)
|
||||||
|
ts = date.Time
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.timestamp != nil {
|
||||||
|
*t.timestamp = nostr.Timestamp(ts.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.hasBeenSet = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a readable representation of this value (for usage defaults)
|
||||||
|
func (t *naturalTimeValue) String() string {
|
||||||
|
return fmt.Sprintf("%#v", t.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the timestamp value stored in the flag
|
||||||
|
func (t *naturalTimeValue) Value() *nostr.Timestamp {
|
||||||
|
return t.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the flag structure
|
||||||
|
func (t *naturalTimeValue) Get() any {
|
||||||
|
return *t.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
|
||||||
|
return cmd.Value(name).(nostr.Timestamp)
|
||||||
|
}
|
||||||
129
fs.go
Normal file
129
fs.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/fiatjaf/nak/nostrfs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/keyer"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fsCmd = &cli.Command{
|
||||||
|
Name: "fs",
|
||||||
|
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||||
|
Description: `(experimental)`,
|
||||||
|
ArgsUsage: "<mountpoint>",
|
||||||
|
Flags: append(defaultKeyFlags,
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Usage: "public key from where to to prepopulate directories",
|
||||||
|
Validator: func(pk string) error {
|
||||||
|
if nostr.IsValidPublicKey(pk) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid public key '%s'", pk)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "auto-publish-notes",
|
||||||
|
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||||
|
Value: time.Second * 30,
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "auto-publish-articles",
|
||||||
|
Usage: "delay after which edited articles will be auto-published.",
|
||||||
|
Value: time.Hour * 24 * 365 * 2,
|
||||||
|
DefaultText: "basically infinite",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
mountpoint := c.Args().First()
|
||||||
|
if mountpoint == "" {
|
||||||
|
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
var kr nostr.User
|
||||||
|
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||||
|
kr = signer
|
||||||
|
} else {
|
||||||
|
kr = keyer.NewReadOnlyUser(c.String("pubkey"))
|
||||||
|
}
|
||||||
|
|
||||||
|
apnt := c.Duration("auto-publish-notes")
|
||||||
|
if apnt < 0 {
|
||||||
|
apnt = time.Hour * 24 * 365 * 3
|
||||||
|
}
|
||||||
|
apat := c.Duration("auto-publish-articles")
|
||||||
|
if apat < 0 {
|
||||||
|
apat = time.Hour * 24 * 365 * 3
|
||||||
|
}
|
||||||
|
|
||||||
|
root := nostrfs.NewNostrRoot(
|
||||||
|
context.WithValue(
|
||||||
|
context.WithValue(
|
||||||
|
ctx,
|
||||||
|
"log", log,
|
||||||
|
),
|
||||||
|
"logverbose", logverbose,
|
||||||
|
),
|
||||||
|
sys,
|
||||||
|
kr,
|
||||||
|
mountpoint,
|
||||||
|
nostrfs.Options{
|
||||||
|
AutoPublishNotesTimeout: apnt,
|
||||||
|
AutoPublishArticlesTimeout: apat,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// create the server
|
||||||
|
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||||
|
timeout := time.Second * 120
|
||||||
|
server, err := fs.Mount(mountpoint, root, &fs.Options{
|
||||||
|
MountOptions: fuse.MountOptions{
|
||||||
|
Debug: isVerbose,
|
||||||
|
Name: "nak",
|
||||||
|
FsName: "nak",
|
||||||
|
RememberInodes: true,
|
||||||
|
},
|
||||||
|
AttrTimeout: &timeout,
|
||||||
|
EntryTimeout: &timeout,
|
||||||
|
Logger: nostr.DebugLogger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mount failed: %w", err)
|
||||||
|
}
|
||||||
|
log("ok.\n")
|
||||||
|
|
||||||
|
// setup signal handling for clean unmount
|
||||||
|
ch := make(chan os.Signal, 1)
|
||||||
|
chErr := make(chan error)
|
||||||
|
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-ch
|
||||||
|
log("- unmounting... ")
|
||||||
|
err := server.Unmount()
|
||||||
|
if err != nil {
|
||||||
|
chErr <- fmt.Errorf("unmount failed: %w", err)
|
||||||
|
} else {
|
||||||
|
log("ok\n")
|
||||||
|
chErr <- nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// serve the filesystem until unmounted
|
||||||
|
server.Wait()
|
||||||
|
return <-chErr
|
||||||
|
},
|
||||||
|
}
|
||||||
20
fs_windows.go
Normal file
20
fs_windows.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fsCmd = &cli.Command{
|
||||||
|
Name: "fs",
|
||||||
|
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||||
|
Description: `doesn't work on Windows.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return fmt.Errorf("this doesn't work on Windows.")
|
||||||
|
},
|
||||||
|
}
|
||||||
92
go.mod
92
go.mod
@@ -1,37 +1,77 @@
|
|||||||
module github.com/fiatjaf/nak
|
module github.com/fiatjaf/nak
|
||||||
|
|
||||||
go 1.21
|
go 1.24.1
|
||||||
|
|
||||||
toolchain go1.21.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bgentry/speakeasy v0.1.0
|
fiatjaf.com/lib v0.3.1
|
||||||
github.com/mailru/easyjson v0.7.7
|
github.com/bep/debounce v1.2.1
|
||||||
github.com/manifoldco/promptui v0.9.0
|
github.com/btcsuite/btcd/btcec/v2 v2.3.4
|
||||||
github.com/nbd-wtf/go-nostr v0.28.1
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||||
github.com/nbd-wtf/nostr-sdk v0.0.5
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||||
github.com/urfave/cli/v2 v2.25.7
|
github.com/fatih/color v1.16.0
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
|
github.com/fiatjaf/eventstore v0.16.2
|
||||||
|
github.com/fiatjaf/khatru v0.17.4
|
||||||
|
github.com/hanwen/go-fuse/v2 v2.7.2
|
||||||
|
github.com/json-iterator/go v1.1.12
|
||||||
|
github.com/liamg/magic v0.0.1
|
||||||
|
github.com/mailru/easyjson v0.9.0
|
||||||
|
github.com/mark3labs/mcp-go v0.8.3
|
||||||
|
github.com/markusmobius/go-dateparser v1.2.3
|
||||||
|
github.com/nbd-wtf/go-nostr v0.51.8
|
||||||
|
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||||
|
golang.org/x/term v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
|
github.com/btcsuite/btcd v0.24.2 // indirect
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/fiatjaf/eventstore v0.2.16 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/chzyer/logex v1.1.10 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
|
||||||
github.com/gobwas/ws v1.3.1 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/coder/websocket v1.8.13 // indirect
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||||
|
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elliotchance/pie/v2 v2.7.0 // indirect
|
||||||
|
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 // indirect
|
||||||
|
github.com/fasthttp/websocket v1.5.12 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hablullah/go-hijri v1.0.2 // indirect
|
||||||
|
github.com/hablullah/go-juliandays v1.0.0 // indirect
|
||||||
|
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.0.2 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/tidwall/gjson v1.17.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
|
github.com/magefile/mage v1.14.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
|
github.com/rs/cors v1.11.1 // indirect
|
||||||
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||||
|
github.com/tetratelabs/wazero v1.8.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
golang.org/x/sys v0.14.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.59.0 // indirect
|
||||||
|
github.com/wasilibs/go-re2 v1.3.0 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
golang.org/x/arch v0.15.0 // indirect
|
||||||
|
golang.org/x/crypto v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.37.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
208
go.sum
208
go.sum
@@ -1,21 +1,29 @@
|
|||||||
|
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||||
|
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||||
|
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||||
|
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
|
||||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||||
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||||
github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||||
|
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||||
|
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
|
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ=
|
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0=
|
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||||
@@ -25,35 +33,58 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
|
|||||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||||
|
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
|
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||||
github.com/fiatjaf/eventstore v0.2.16 h1:NR64mnyUT5nJR8Sj2AwJTd1Hqs5kKJcCFO21ggUkvWg=
|
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
|
||||||
github.com/fiatjaf/eventstore v0.2.16/go.mod h1:rUc1KhVufVmC+HUOiuPweGAcvG6lEOQCkRCn2Xn5VRA=
|
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||||
|
github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg=
|
||||||
|
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
|
||||||
|
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 h1:k7evIqJ2BtFn191DgY/b03N2bMYA/iQwzr4f/uHYn20=
|
||||||
|
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
|
||||||
|
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
|
||||||
|
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/fiatjaf/eventstore v0.16.2 h1:h4rHwSwPcqAKqWUsAbYWUhDeSgm2Kp+PBkJc3FgBYu4=
|
||||||
|
github.com/fiatjaf/eventstore v0.16.2/go.mod h1:0gU8fzYO/bG+NQAVlHtJWOlt3JKKFefh5Xjj2d1dLIs=
|
||||||
|
github.com/fiatjaf/khatru v0.17.4 h1:VzcLUyBKMlP/CAG4iHJbDJmnZgzhbGLKLxJAUuLRogg=
|
||||||
|
github.com/fiatjaf/khatru v0.17.4/go.mod h1:VYQ7ZNhs3C1+E4gBnx+DtEgU0BrPdrl3XYF3H+mq6fg=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
|
|
||||||
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
@@ -65,21 +96,61 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
|||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
|
||||||
|
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
||||||
|
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||||
|
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
||||||
|
github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
|
||||||
|
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
|
||||||
|
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
|
||||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/nbd-wtf/go-nostr v0.28.1 h1:XQi/lBsigBXHRm7IDBJE7SR9citCh9srgf8sA5iVW3A=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/nbd-wtf/go-nostr v0.28.1/go.mod h1:OQ8sNLFJnsj17BdqZiLSmjJBIFTfDqckEYC3utS4qoY=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/nbd-wtf/nostr-sdk v0.0.5 h1:rec+FcDizDVO0W25PX0lgYMXvP7zNNOgI3Fu9UCm4BY=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/nbd-wtf/nostr-sdk v0.0.5/go.mod h1:iJJsikesCGLNFZ9dLqhLPDzdt924EagUmdQxT3w2Lmk=
|
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/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||||
|
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
|
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||||
|
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
|
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
|
||||||
|
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
|
||||||
|
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
|
||||||
|
github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||||
|
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/nbd-wtf/go-nostr v0.51.8 h1:CIoS+YqChcm4e1L1rfMZ3/mIwTz4CwApM2qx7MHNzmE=
|
||||||
|
github.com/nbd-wtf/go-nostr v0.51.8/go.mod h1:d6+DfvMWYG5pA3dmNMBJd6WCHVDDhkXbHqvfljf0Gzg=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
@@ -89,40 +160,71 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5
|
|||||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew=
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
||||||
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||||
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
|
github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g=
|
||||||
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
|
||||||
|
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
|
||||||
|
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
|
||||||
|
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||||
|
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||||
|
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||||
|
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -131,12 +233,17 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -153,3 +260,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
426
helpers.go
426
helpers.go
@@ -3,83 +3,137 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bgentry/speakeasy"
|
"github.com/fatih/color"
|
||||||
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip19"
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var sys *sdk.System
|
||||||
|
|
||||||
|
var (
|
||||||
|
hintsFilePath string
|
||||||
|
hintsFileExists bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var json = jsoniter.ConfigFastest
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LINE_PROCESSING_ERROR = iota
|
LINE_PROCESSING_ERROR = iota
|
||||||
|
|
||||||
BOLD_ON = "\033[1m"
|
|
||||||
BOLD_OFF = "\033[21m"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var log = func(msg string, args ...any) {
|
var (
|
||||||
fmt.Fprintf(os.Stderr, msg, args...)
|
log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) }
|
||||||
}
|
logverbose = func(msg string, args ...any) {} // by default do nothing
|
||||||
|
stdout = fmt.Println
|
||||||
var stdout = fmt.Println
|
)
|
||||||
|
|
||||||
func isPiped() bool {
|
func isPiped() bool {
|
||||||
stat, _ := os.Stdin.Stat()
|
stat, _ := os.Stdin.Stat()
|
||||||
return stat.Mode()&os.ModeCharDevice == 0
|
return stat.Mode()&os.ModeCharDevice == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStdinLinesOrBlank() chan string {
|
func getJsonsOrBlank() iter.Seq[string] {
|
||||||
multi := make(chan string)
|
var curr strings.Builder
|
||||||
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
|
|
||||||
single := make(chan string, 1)
|
return func(yield func(string) bool) {
|
||||||
single <- ""
|
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||||
close(single)
|
// we're look for an event, but it may be in multiple lines, so if json parsing fails
|
||||||
return single
|
// we'll try the next line until we're successful
|
||||||
} else {
|
curr.WriteString(stdinLine)
|
||||||
return multi
|
stdinEvent := curr.String()
|
||||||
|
|
||||||
|
var dummy any
|
||||||
|
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !yield(stdinEvent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
curr.Reset()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !hasStdin {
|
||||||
|
yield("{}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStdinLinesOrFirstArgument(c *cli.Context) chan string {
|
func getStdinLinesOrBlank() iter.Seq[string] {
|
||||||
|
return func(yield func(string) bool) {
|
||||||
|
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||||
|
if !yield(stdinLine) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !hasStdin {
|
||||||
|
yield("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
|
||||||
|
return getStdinLinesOrArgumentsFromSlice(args.Slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
|
||||||
// try the first argument
|
// try the first argument
|
||||||
target := c.Args().First()
|
if len(args) > 0 {
|
||||||
if target != "" {
|
return slices.Values(args)
|
||||||
single := make(chan string, 1)
|
|
||||||
single <- target
|
|
||||||
close(single)
|
|
||||||
return single
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try the stdin
|
// try the stdin
|
||||||
multi := make(chan string)
|
return func(yield func(string) bool) {
|
||||||
writeStdinLinesOrNothing(multi)
|
writeStdinLinesOrNothing(yield)
|
||||||
return multi
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
|
func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
|
||||||
if isPiped() {
|
if isPiped() {
|
||||||
// piped
|
// piped
|
||||||
go func() {
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
|
||||||
scanner.Buffer(make([]byte, 16*1024), 256*1024)
|
hasEmittedAtLeastOne := false
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
ch <- strings.TrimSpace(scanner.Text())
|
if !yield(strings.TrimSpace(scanner.Text())) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
close(ch)
|
hasEmittedAtLeastOne = true
|
||||||
}()
|
}
|
||||||
return true
|
return hasEmittedAtLeastOne
|
||||||
} else {
|
} else {
|
||||||
// not piped
|
// not piped
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRelayURLs(wsurls []string) error {
|
func normalizeAndValidateRelayURLs(wsurls []string) error {
|
||||||
for _, wsurl := range wsurls {
|
for i, wsurl := range wsurls {
|
||||||
|
wsurl = nostr.NormalizeURL(wsurl)
|
||||||
|
wsurls[i] = wsurl
|
||||||
|
|
||||||
u, err := url.Parse(wsurl)
|
u, err := url.Parse(wsurl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid relay url '%s': %s", wsurl, err)
|
return fmt.Errorf("invalid relay url '%s': %s", wsurl, err)
|
||||||
@@ -99,60 +153,270 @@ func validateRelayURLs(wsurls []string) error {
|
|||||||
|
|
||||||
func connectToAllRelays(
|
func connectToAllRelays(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
c *cli.Command,
|
||||||
relayUrls []string,
|
relayUrls []string,
|
||||||
|
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error), // if this exists we will force preauth
|
||||||
opts ...nostr.PoolOption,
|
opts ...nostr.PoolOption,
|
||||||
) (*nostr.SimplePool, []*nostr.Relay) {
|
) []*nostr.Relay {
|
||||||
relays := make([]*nostr.Relay, 0, len(relayUrls))
|
// first pass to check if these are valid relay URLs
|
||||||
pool := nostr.NewSimplePool(ctx, opts...)
|
|
||||||
for _, url := range relayUrls {
|
for _, url := range relayUrls {
|
||||||
log("connecting to %s... ", url)
|
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
|
||||||
if relay, err := pool.EnsureRelay(url); err == nil {
|
log("invalid relay URL: %s\n", url)
|
||||||
relays = append(relays, relay)
|
os.Exit(4)
|
||||||
log("ok.\n")
|
|
||||||
} else {
|
|
||||||
log(err.Error() + "\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pool, relays
|
|
||||||
|
sys.Pool = nostr.NewSimplePool(context.Background(),
|
||||||
|
append(opts,
|
||||||
|
nostr.WithEventMiddleware(sys.TrackEventHints),
|
||||||
|
nostr.WithPenaltyBox(),
|
||||||
|
nostr.WithRelayOptions(
|
||||||
|
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}}),
|
||||||
|
),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
relays := make([]*nostr.Relay, 0, len(relayUrls))
|
||||||
|
|
||||||
|
if supportsDynamicMultilineMagic() {
|
||||||
|
// overcomplicated multiline rendering magic
|
||||||
|
lines := make([][][]byte, len(relayUrls))
|
||||||
|
flush := func() {
|
||||||
|
for _, line := range lines {
|
||||||
|
for _, part := range line {
|
||||||
|
os.Stderr.Write(part)
|
||||||
|
}
|
||||||
|
os.Stderr.Write([]byte{'\n'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render := func() {
|
||||||
|
clearLines(len(lines))
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(len(relayUrls))
|
||||||
|
for i, url := range relayUrls {
|
||||||
|
lines[i] = make([][]byte, 1, 2)
|
||||||
|
logthis := func(s string, args ...any) {
|
||||||
|
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
colorizepreamble := func(c func(string, ...any) string) {
|
||||||
|
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
|
||||||
|
}
|
||||||
|
colorizepreamble(color.CyanString)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
|
||||||
|
if relay != nil {
|
||||||
|
relays = append(relays, relay)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
} else {
|
||||||
|
// simple flow
|
||||||
|
for _, url := range relayUrls {
|
||||||
|
log("connecting to %s... ", color.CyanString(url))
|
||||||
|
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
|
||||||
|
if relay != nil {
|
||||||
|
relays = append(relays, relay)
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return relays
|
||||||
}
|
}
|
||||||
|
|
||||||
func lineProcessingError(c *cli.Context, msg string, args ...any) {
|
func connectToSingleRelay(
|
||||||
c.Context = context.WithValue(c.Context, LINE_PROCESSING_ERROR, true)
|
ctx context.Context,
|
||||||
|
c *cli.Command,
|
||||||
|
url string,
|
||||||
|
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error),
|
||||||
|
colorizepreamble func(c func(string, ...any) string),
|
||||||
|
logthis func(s string, args ...any),
|
||||||
|
) *nostr.Relay {
|
||||||
|
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
|
||||||
|
if preAuthSigner != nil {
|
||||||
|
if colorizepreamble != nil {
|
||||||
|
colorizepreamble(color.YellowString)
|
||||||
|
}
|
||||||
|
logthis("waiting for auth challenge... ")
|
||||||
|
time.Sleep(time.Millisecond * 200)
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
|
||||||
|
challengeTag := authEvent.Tags.Find("challenge")
|
||||||
|
if challengeTag[1] == "" {
|
||||||
|
return fmt.Errorf("auth not received yet *****") // what a giant hack
|
||||||
|
}
|
||||||
|
return preAuthSigner(ctx, c, logthis, nostr.RelayEvent{Event: authEvent, Relay: relay})
|
||||||
|
}); err == nil {
|
||||||
|
// auth succeeded
|
||||||
|
goto preauthSuccess
|
||||||
|
} else {
|
||||||
|
// auth failed
|
||||||
|
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
|
||||||
|
// it failed because we didn't receive the challenge yet, so keep waiting
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// it failed for some other reason, so skip this relay
|
||||||
|
if colorizepreamble != nil {
|
||||||
|
colorizepreamble(colors.errorf)
|
||||||
|
}
|
||||||
|
logthis(err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if colorizepreamble != nil {
|
||||||
|
colorizepreamble(colors.errorf)
|
||||||
|
}
|
||||||
|
logthis("failed to get an AUTH challenge in enough time.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
preauthSuccess:
|
||||||
|
if colorizepreamble != nil {
|
||||||
|
colorizepreamble(colors.successf)
|
||||||
|
}
|
||||||
|
logthis("ok.")
|
||||||
|
return relay
|
||||||
|
} else {
|
||||||
|
if colorizepreamble != nil {
|
||||||
|
colorizepreamble(colors.errorf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we're here that means we've failed to connect, this may be a huge message
|
||||||
|
// but we're likely to only be interested in the lowest level error (although we can leave space)
|
||||||
|
logthis(clampError(err, len(url)+12))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLines(lineCount int) {
|
||||||
|
for i := 0; i < lineCount; i++ {
|
||||||
|
os.Stderr.Write([]byte("\033[0A\033[2K\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsDynamicMultilineMagic() bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !term.IsTerminal(0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
width, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if width < 110 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(authEvent.Relay.URL, "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.EncodePublicKey(pk)
|
||||||
|
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
|
||||||
|
|
||||||
|
return kr.SignEvent(ctx, authEvent.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
|
||||||
log(msg+"\n", args...)
|
log(msg+"\n", args...)
|
||||||
|
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitIfLineProcessingError(c *cli.Context) {
|
func exitIfLineProcessingError(ctx context.Context) {
|
||||||
if val := c.Context.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
|
if val := ctx.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
|
||||||
os.Exit(123)
|
os.Exit(123)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatherSecretKeyFromArguments(c *cli.Context) (string, error) {
|
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
sec := c.String("sec")
|
|
||||||
if c.Bool("prompt-sec") {
|
|
||||||
if isPiped() {
|
|
||||||
return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
sec, err = speakeasy.FAsk(os.Stderr, "type your secret key as nsec or hex: ")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get secret key: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(sec, "nsec1") {
|
|
||||||
_, hex, err := nip19.Decode(sec)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid nsec: %w", err)
|
|
||||||
}
|
|
||||||
sec = hex.(string)
|
|
||||||
}
|
|
||||||
if len(sec) > 64 {
|
|
||||||
return "", fmt.Errorf("invalid secret key: too large")
|
|
||||||
}
|
|
||||||
sec = strings.Repeat("0", 64-len(sec)) + sec // left-pad
|
|
||||||
if ok := nostr.IsValid32ByteHex(sec); !ok {
|
|
||||||
return "", fmt.Errorf("invalid secret key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sec, nil
|
func randString(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leftPadKey(k string) string {
|
||||||
|
return strings.Repeat("0", 64-len(k)) + k
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapAll(err error) error {
|
||||||
|
low := err
|
||||||
|
for n := low; n != nil; n = errors.Unwrap(low) {
|
||||||
|
low = n
|
||||||
|
}
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampMessage(msg string, prefixAlreadyPrinted int) string {
|
||||||
|
termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
|
||||||
|
|
||||||
|
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
|
||||||
|
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampError(err error, prefixAlreadyPrinted int) string {
|
||||||
|
termSize, _, _ := term.GetSize(0)
|
||||||
|
msg := err.Error()
|
||||||
|
if len(msg) > termSize-prefixAlreadyPrinted {
|
||||||
|
err = unwrapAll(err)
|
||||||
|
msg = clampMessage(err.Error(), prefixAlreadyPrinted)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
var colors = struct {
|
||||||
|
reset func(...any) (int, error)
|
||||||
|
italic func(...any) string
|
||||||
|
italicf func(string, ...any) string
|
||||||
|
bold func(...any) string
|
||||||
|
boldf func(string, ...any) string
|
||||||
|
error func(...any) string
|
||||||
|
errorf func(string, ...any) string
|
||||||
|
success func(...any) string
|
||||||
|
successf func(string, ...any) string
|
||||||
|
}{
|
||||||
|
color.New(color.Reset).Print,
|
||||||
|
color.New(color.Italic).Sprint,
|
||||||
|
color.New(color.Italic).Sprintf,
|
||||||
|
color.New(color.Bold).Sprint,
|
||||||
|
color.New(color.Bold).Sprintf,
|
||||||
|
color.New(color.Bold, color.FgHiRed).Sprint,
|
||||||
|
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||||
|
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||||
|
color.New(color.Bold, color.FgHiGreen).Sprintf,
|
||||||
}
|
}
|
||||||
|
|||||||
161
helpers_key.go
Normal file
161
helpers_key.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/keyer"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip46"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip49"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultKeyFlags = []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sec",
|
||||||
|
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL, it is more secure to use the environment variable NOSTR_SECRET_KEY than this flag",
|
||||||
|
DefaultText: "the key '1'",
|
||||||
|
Aliases: []string{"connect"},
|
||||||
|
Category: CATEGORY_SIGNER,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "prompt-sec",
|
||||||
|
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||||
|
Category: CATEGORY_SIGNER,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "connect-as",
|
||||||
|
Usage: "private key to use when communicating with nip46 bunkers",
|
||||||
|
DefaultText: "a random key",
|
||||||
|
Category: CATEGORY_SIGNER,
|
||||||
|
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, string, error) {
|
||||||
|
key, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var kr nostr.Keyer
|
||||||
|
if bunker != nil {
|
||||||
|
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
|
||||||
|
} else {
|
||||||
|
kr, err = keyer.NewPlainKeySigner(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return kr, key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (string, *nip46.BunkerClient, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
sec := c.String("sec")
|
||||||
|
if strings.HasPrefix(sec, "bunker://") {
|
||||||
|
// it's a bunker
|
||||||
|
bunkerURL := sec
|
||||||
|
clientKey := c.String("connect-as")
|
||||||
|
if clientKey != "" {
|
||||||
|
clientKey = strings.Repeat("0", 64-len(clientKey)) + clientKey
|
||||||
|
} else {
|
||||||
|
clientKey = nostr.GeneratePrivateKey()
|
||||||
|
}
|
||||||
|
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
|
||||||
|
log(color.CyanString("[nip46]: open the following URL: %s"), s)
|
||||||
|
})
|
||||||
|
return "", bunker, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// take private from flags, environment variable or default to 1
|
||||||
|
if sec == "" {
|
||||||
|
if key, ok := os.LookupEnv("NOSTR_SECRET_KEY"); ok {
|
||||||
|
sec = key
|
||||||
|
} else {
|
||||||
|
sec = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Bool("prompt-sec") {
|
||||||
|
if isPiped() {
|
||||||
|
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
|
||||||
|
}
|
||||||
|
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to get secret key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(sec, "ncryptsec1") {
|
||||||
|
sec, err = promptDecrypt(sec)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
} else if bsec, err := hex.DecodeString(leftPadKey(sec)); err == nil {
|
||||||
|
sec = hex.EncodeToString(bsec)
|
||||||
|
} else if prefix, hexvalue, err := nip19.Decode(sec); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid nsec: %w", err)
|
||||||
|
} else if prefix == "nsec" {
|
||||||
|
sec = hexvalue.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := nostr.IsValid32ByteHex(sec); !ok {
|
||||||
|
return "", nil, fmt.Errorf("invalid secret key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sec, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDecrypt(ncryptsec string) (string, error) {
|
||||||
|
for i := 1; i < 4; i++ {
|
||||||
|
var attemptStr string
|
||||||
|
if i > 1 {
|
||||||
|
attemptStr = fmt.Sprintf(" [%d/3]", i)
|
||||||
|
}
|
||||||
|
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return sec, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("couldn't decrypt private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
|
||||||
|
config := &readline.Config{
|
||||||
|
Stdout: color.Error,
|
||||||
|
Prompt: color.YellowString(msg),
|
||||||
|
InterruptPrompt: "^C",
|
||||||
|
DisableAutoSaveHistory: true,
|
||||||
|
EnableMask: true,
|
||||||
|
MaskRune: '*',
|
||||||
|
}
|
||||||
|
|
||||||
|
rl, err := readline.NewEx(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
answer, err := rl.Readline()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
answer = strings.TrimSpace(answer)
|
||||||
|
if shouldAskAgain != nil && shouldAskAgain(answer) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return answer, err
|
||||||
|
}
|
||||||
|
}
|
||||||
292
key.go
Normal file
292
key.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip49"
|
||||||
|
)
|
||||||
|
|
||||||
|
var key = &cli.Command{
|
||||||
|
Name: "key",
|
||||||
|
Usage: "operations on secret keys: generate, derive, encrypt, decrypt",
|
||||||
|
Description: ``,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
generate,
|
||||||
|
public,
|
||||||
|
encryptKey,
|
||||||
|
decryptKey,
|
||||||
|
combine,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var generate = &cli.Command{
|
||||||
|
Name: "generate",
|
||||||
|
Usage: "generates a secret key",
|
||||||
|
Description: ``,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
sec := nostr.GeneratePrivateKey()
|
||||||
|
stdout(sec)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var public = &cli.Command{
|
||||||
|
Name: "public",
|
||||||
|
Usage: "computes a public key from a secret key",
|
||||||
|
Description: ``,
|
||||||
|
ArgsUsage: "[secret]",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "with-parity",
|
||||||
|
Usage: "output 33 bytes instead of 32, the first one being either '02' or '03', a prefix indicating whether this pubkey is even or odd.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
|
||||||
|
b, _ := hex.DecodeString(sec)
|
||||||
|
_, pk := btcec.PrivKeyFromBytes(b)
|
||||||
|
|
||||||
|
if c.Bool("with-parity") {
|
||||||
|
stdout(hex.EncodeToString(pk.SerializeCompressed()))
|
||||||
|
} else {
|
||||||
|
stdout(hex.EncodeToString(pk.SerializeCompressed()[1:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encryptKey = &cli.Command{
|
||||||
|
Name: "encrypt",
|
||||||
|
Usage: "encrypts a secret key and prints an ncryptsec code",
|
||||||
|
Description: `uses the nip49 standard.`,
|
||||||
|
ArgsUsage: "<secret> <password>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "logn",
|
||||||
|
Usage: "the bigger the number the harder it will be to bruteforce the password",
|
||||||
|
Value: 16,
|
||||||
|
DefaultText: "16",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
keys := make([]string, 0, 1)
|
||||||
|
var password string
|
||||||
|
switch c.Args().Len() {
|
||||||
|
case 1:
|
||||||
|
password = c.Args().Get(0)
|
||||||
|
case 2:
|
||||||
|
keys = append(keys, c.Args().Get(0))
|
||||||
|
password = c.Args().Get(1)
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("no password given")
|
||||||
|
}
|
||||||
|
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, keys) {
|
||||||
|
ncryptsec, err := nip49.Encrypt(sec, password, uint8(c.Int("logn")), 0x02)
|
||||||
|
if err != nil {
|
||||||
|
ctx = lineProcessingError(ctx, "failed to encrypt: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stdout(ncryptsec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var decryptKey = &cli.Command{
|
||||||
|
Name: "decrypt",
|
||||||
|
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
|
||||||
|
Description: `uses the nip49 standard.`,
|
||||||
|
ArgsUsage: "<ncryptsec-code> <password>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
var ncryptsec string
|
||||||
|
var password string
|
||||||
|
switch c.Args().Len() {
|
||||||
|
case 2:
|
||||||
|
ncryptsec = c.Args().Get(0)
|
||||||
|
password = c.Args().Get(1)
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("no password given")
|
||||||
|
}
|
||||||
|
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt: %s", err)
|
||||||
|
}
|
||||||
|
stdout(sec)
|
||||||
|
return nil
|
||||||
|
case 1:
|
||||||
|
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
|
||||||
|
ncryptsec = arg
|
||||||
|
if res, err := promptDecrypt(ncryptsec); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
stdout(res)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password = c.Args().Get(0)
|
||||||
|
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
|
||||||
|
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||||
|
if err != nil {
|
||||||
|
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stdout(sec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid number of arguments")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var combine = &cli.Command{
|
||||||
|
Name: "combine",
|
||||||
|
Usage: "combines two or more pubkeys using musig2",
|
||||||
|
Description: `The public keys must have 33 bytes (66 characters hex), with the 02 or 03 prefix. It is common in Nostr to drop that first byte, so you'll have to derive the public keys again from the private keys in order to get it back.
|
||||||
|
|
||||||
|
However, if the intent is to check if two existing Nostr pubkeys match a given combined pubkey, then it might be sufficient to calculate the combined key for all the possible combinations of pubkeys in the input.`,
|
||||||
|
ArgsUsage: "[pubkey...]",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
type Combination struct {
|
||||||
|
Variants []string `json:"input_variants"`
|
||||||
|
Output struct {
|
||||||
|
XOnly string `json:"x_only"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
} `json:"combined_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
Combinations []Combination `json:"combinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Result{}
|
||||||
|
|
||||||
|
result.Keys = c.Args().Slice()
|
||||||
|
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
|
||||||
|
|
||||||
|
for i, keyhex := range result.Keys {
|
||||||
|
keyb, err := hex.DecodeString(keyhex)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
|
||||||
|
group := make([]*btcec.PublicKey, 2)
|
||||||
|
for i, prefix := range []byte{0x02, 0x03} {
|
||||||
|
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
|
||||||
|
if err != nil {
|
||||||
|
log("error parsing key %s: %s", keyhex, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group[i] = pubk
|
||||||
|
}
|
||||||
|
keyGroups = append(keyGroups, group)
|
||||||
|
} else /* assume it's 33 */ {
|
||||||
|
pubk, err := btcec.ParsePubKey(keyb)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||||
|
}
|
||||||
|
keyGroups = append(keyGroups, []*btcec.PublicKey{pubk})
|
||||||
|
|
||||||
|
// remove the leading byte from the output just so it is all uniform
|
||||||
|
result.Keys[i] = result.Keys[i][2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Combinations = make([]Combination, 0, 16)
|
||||||
|
|
||||||
|
var fn func(prepend int, curr []int)
|
||||||
|
fn = func(prepend int, curr []int) {
|
||||||
|
curr = append([]int{prepend}, curr...)
|
||||||
|
if len(curr) == len(keyGroups) {
|
||||||
|
combi := Combination{
|
||||||
|
Variants: make([]string, len(keyGroups)),
|
||||||
|
}
|
||||||
|
|
||||||
|
combining := make([]*btcec.PublicKey, len(keyGroups))
|
||||||
|
for g, altKeys := range keyGroups {
|
||||||
|
altKey := altKeys[curr[g]]
|
||||||
|
variant := secp256k1.PubKeyFormatCompressedEven
|
||||||
|
if altKey.Y().Bit(0) == 1 {
|
||||||
|
variant = secp256k1.PubKeyFormatCompressedOdd
|
||||||
|
}
|
||||||
|
combi.Variants[g] = hex.EncodeToString([]byte{variant})
|
||||||
|
combining[g] = altKey
|
||||||
|
}
|
||||||
|
|
||||||
|
agg, _, _, err := musig2.AggregateKeys(combining, true)
|
||||||
|
if err != nil {
|
||||||
|
log("error aggregating: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serialized := agg.FinalKey.SerializeCompressed()
|
||||||
|
combi.Output.XOnly = hex.EncodeToString(serialized[1:])
|
||||||
|
combi.Output.Variant = hex.EncodeToString(serialized[0:1])
|
||||||
|
result.Combinations = append(result.Combinations, combi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn(0, curr)
|
||||||
|
if len(keyGroups[len(keyGroups)-len(curr)-1]) > 1 {
|
||||||
|
fn(1, curr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn(0, nil)
|
||||||
|
if len(keyGroups[len(keyGroups)-1]) > 1 {
|
||||||
|
fn(1, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
stdout(string(res))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan string {
|
||||||
|
ch := make(chan string)
|
||||||
|
go func() {
|
||||||
|
for sec := range getStdinLinesOrArgumentsFromSlice(keys) {
|
||||||
|
if sec == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(sec, "nsec1") {
|
||||||
|
_, data, err := nip19.Decode(sec)
|
||||||
|
if err != nil {
|
||||||
|
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sec = data.(string)
|
||||||
|
}
|
||||||
|
sec = leftPadKey(sec)
|
||||||
|
if !nostr.IsValid32ByteHex(sec) {
|
||||||
|
ctx = lineProcessingError(ctx, "invalid hex key")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch <- sec
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
139
main.go
139
main.go
@@ -1,51 +1,162 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
"github.com/nbd-wtf/go-nostr/sdk/hints/memoryh"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var q int
|
var (
|
||||||
|
version string = "debug"
|
||||||
|
isVerbose bool = false
|
||||||
|
)
|
||||||
|
|
||||||
var app = &cli.App{
|
var app = &cli.Command{
|
||||||
Name: "nak",
|
Name: "nak",
|
||||||
Suggest: true,
|
Suggest: true,
|
||||||
UseShortOptionHandling: true,
|
UseShortOptionHandling: true,
|
||||||
Usage: "the nostr army knife command-line tool",
|
Usage: "the nostr army knife command-line tool",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
req,
|
|
||||||
count,
|
|
||||||
fetch,
|
|
||||||
event,
|
event,
|
||||||
|
req,
|
||||||
|
fetch,
|
||||||
|
count,
|
||||||
decode,
|
decode,
|
||||||
encode,
|
encode,
|
||||||
|
key,
|
||||||
verify,
|
verify,
|
||||||
relay,
|
relay,
|
||||||
bunker,
|
bunker,
|
||||||
|
serve,
|
||||||
|
blossomCmd,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
outbox,
|
||||||
|
wallet,
|
||||||
|
mcpServer,
|
||||||
|
curl,
|
||||||
|
dvm,
|
||||||
|
fsCmd,
|
||||||
},
|
},
|
||||||
|
Version: version,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config-path",
|
||||||
|
Hidden: true,
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "quiet",
|
Name: "quiet",
|
||||||
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
||||||
Count: &q,
|
|
||||||
Aliases: []string{"q"},
|
Aliases: []string{"q"},
|
||||||
Action: func(ctx *cli.Context, b bool) error {
|
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||||
|
q := c.Count("quiet")
|
||||||
if q >= 1 {
|
if q >= 1 {
|
||||||
log = func(msg string, args ...any) {}
|
log = func(msg string, args ...any) {}
|
||||||
if q >= 2 {
|
if q >= 2 {
|
||||||
stdout = func(a ...any) (int, error) { return 0, nil }
|
stdout = func(_ ...any) (int, error) { return 0, nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "verbose",
|
||||||
|
Usage: "print more stuff than normally",
|
||||||
|
Aliases: []string{"v"},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||||
|
v := c.Count("verbose")
|
||||||
|
if v >= 1 {
|
||||||
|
logverbose = log
|
||||||
|
isVerbose = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||||
|
configPath := c.String("config-path")
|
||||||
|
if configPath == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
configPath = filepath.Join(home, ".config/nak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if configPath != "" {
|
||||||
|
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
|
||||||
|
}
|
||||||
|
if hintsFilePath != "" {
|
||||||
|
if _, err := os.Stat(hintsFilePath); !os.IsNotExist(err) {
|
||||||
|
hintsFileExists = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hintsFilePath != "" {
|
||||||
|
if data, err := os.ReadFile(hintsFilePath); err == nil {
|
||||||
|
hintsdb := memoryh.NewHintDB()
|
||||||
|
if err := json.Unmarshal(data, &hintsdb); err == nil {
|
||||||
|
sys = sdk.NewSystem(
|
||||||
|
sdk.WithHintsDB(hintsdb),
|
||||||
|
)
|
||||||
|
goto systemOperational
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sys = sdk.NewSystem()
|
||||||
|
|
||||||
|
systemOperational:
|
||||||
|
sys.Pool = nostr.NewSimplePool(context.Background(),
|
||||||
|
nostr.WithAuthorKindQueryMiddleware(sys.TrackQueryAttempts),
|
||||||
|
nostr.WithEventMiddleware(sys.TrackEventHints),
|
||||||
|
nostr.WithRelayOptions(
|
||||||
|
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
},
|
||||||
|
After: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
// save hints database on exit
|
||||||
|
if hintsFileExists {
|
||||||
|
data, err := json.Marshal(sys.Hints)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(hintsFilePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := app.Run(os.Args); err != nil {
|
defer colors.reset()
|
||||||
|
|
||||||
|
cli.VersionFlag = &cli.BoolFlag{
|
||||||
|
Name: "version",
|
||||||
|
Usage: "prints the version",
|
||||||
|
}
|
||||||
|
|
||||||
|
// a megahack to enable this curl command proxy
|
||||||
|
if len(os.Args) > 2 && os.Args[1] == "curl" {
|
||||||
|
if err := realCurl(); err != nil {
|
||||||
|
stdout(err)
|
||||||
|
colors.reset()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||||
stdout(err)
|
stdout(err)
|
||||||
|
colors.reset()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
244
mcp.go
Normal file
244
mcp.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mcpServer = &cli.Command{
|
||||||
|
Name: "mcp",
|
||||||
|
Usage: "pander to the AI gods",
|
||||||
|
Description: ``,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
s := server.NewMCPServer(
|
||||||
|
"nak",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.AddTool(mcp.NewTool("publish_note",
|
||||||
|
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
|
||||||
|
mcp.WithString("relay",
|
||||||
|
mcp.Description("Relay to publish the note to"),
|
||||||
|
),
|
||||||
|
mcp.WithString("content",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("Arbitrary string to be published"),
|
||||||
|
),
|
||||||
|
mcp.WithString("mention",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("Nostr user's public key to be mentioned"),
|
||||||
|
),
|
||||||
|
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
content, _ := request.Params.Arguments["content"].(string)
|
||||||
|
mention, _ := request.Params.Arguments["mention"].(string)
|
||||||
|
relayI, ok := request.Params.Arguments["relay"]
|
||||||
|
var relay string
|
||||||
|
if ok {
|
||||||
|
relay, _ = relayI.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mention != "" && !nostr.IsValidPublicKey(mention) {
|
||||||
|
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sk := os.Getenv("NOSTR_SECRET_KEY")
|
||||||
|
if sk == "" {
|
||||||
|
sk = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
}
|
||||||
|
var relays []string
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: 1,
|
||||||
|
Tags: nostr.Tags{{"client", "goose/nak"}},
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if mention != "" {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"p", mention})
|
||||||
|
// their inbox relays
|
||||||
|
relays = sys.FetchInboxRelays(ctx, mention, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.Sign(sk)
|
||||||
|
|
||||||
|
// our write relays
|
||||||
|
relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...)
|
||||||
|
|
||||||
|
if len(relays) == 0 {
|
||||||
|
relays = []string{"nos.lol", "relay.damus.io"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra relay specified
|
||||||
|
relays = append(relays, relay)
|
||||||
|
|
||||||
|
result := strings.Builder{}
|
||||||
|
result.WriteString(
|
||||||
|
fmt.Sprintf("the event we generated has id '%s', kind '%d' and is signed by pubkey '%s'. ",
|
||||||
|
evt.ID,
|
||||||
|
evt.Kind,
|
||||||
|
evt.PubKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
|
||||||
|
if res.Error != nil {
|
||||||
|
result.WriteString(
|
||||||
|
fmt.Sprintf("there was an error publishing the event to the relay %s. ",
|
||||||
|
res.RelayURL),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result.WriteString(
|
||||||
|
fmt.Sprintf("the event was successfully published to the relay %s. ",
|
||||||
|
res.RelayURL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result.String()), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(mcp.NewTool("resolve_nostr_uri",
|
||||||
|
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
|
||||||
|
mcp.WithString("uri",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("URI to be resolved"),
|
||||||
|
),
|
||||||
|
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
uri, _ := request.Params.Arguments["uri"].(string)
|
||||||
|
if strings.HasPrefix(uri, "nostr:") {
|
||||||
|
uri = uri[6:]
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, data, err := nip19.Decode(uri)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("this Nostr uri is invalid"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prefix {
|
||||||
|
case "npub":
|
||||||
|
pm := sys.FetchProfileMetadata(ctx, data.(string))
|
||||||
|
return mcp.NewToolResultText(
|
||||||
|
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
|
||||||
|
pm.ShortName(), pm.PubKey),
|
||||||
|
), nil
|
||||||
|
case "nprofile":
|
||||||
|
pm, _ := sys.FetchProfileFromInput(ctx, uri)
|
||||||
|
return mcp.NewToolResultText(
|
||||||
|
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
|
||||||
|
pm.ShortName(), pm.PubKey),
|
||||||
|
), nil
|
||||||
|
case "nevent":
|
||||||
|
event, _, err := sys.FetchSpecificEventFromInput(ctx, uri, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(
|
||||||
|
fmt.Sprintf("this is a Nostr event: %s", event),
|
||||||
|
), nil
|
||||||
|
case "naddr":
|
||||||
|
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
|
||||||
|
default:
|
||||||
|
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(mcp.NewTool("search_profile",
|
||||||
|
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
|
||||||
|
mcp.WithString("name",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("Name to be searched"),
|
||||||
|
),
|
||||||
|
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
name, _ := request.Params.Arguments["name"].(string)
|
||||||
|
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
|
||||||
|
if re == nil {
|
||||||
|
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(re.PubKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
|
||||||
|
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
|
||||||
|
mcp.WithString("pubkey",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("Public key of Nostr user we want to know the relay from where to read"),
|
||||||
|
),
|
||||||
|
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
pubkey, _ := request.Params.Arguments["pubkey"].(string)
|
||||||
|
res := sys.FetchOutboxRelays(ctx, pubkey, 1)
|
||||||
|
return mcp.NewToolResultText(res[0]), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(mcp.NewTool("read_events_from_relay",
|
||||||
|
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
|
||||||
|
mcp.WithNumber("kind",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("event kind number to include in the 'kinds' field"),
|
||||||
|
),
|
||||||
|
mcp.WithString("pubkey",
|
||||||
|
mcp.Description("pubkey to include in the 'authors' field"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("limit",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("maximum number of events to query"),
|
||||||
|
),
|
||||||
|
mcp.WithString("relay",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("relay URL to send the query to"),
|
||||||
|
),
|
||||||
|
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
relay, _ := request.Params.Arguments["relay"].(string)
|
||||||
|
limit, _ := request.Params.Arguments["limit"].(int)
|
||||||
|
kind, _ := request.Params.Arguments["kind"].(int)
|
||||||
|
pubkeyI, ok := request.Params.Arguments["pubkey"]
|
||||||
|
var pubkey string
|
||||||
|
if ok {
|
||||||
|
pubkey, _ = pubkeyI.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
|
||||||
|
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := nostr.Filter{
|
||||||
|
Limit: limit,
|
||||||
|
Kinds: []int{kind},
|
||||||
|
}
|
||||||
|
if pubkey != "" {
|
||||||
|
filter.Authors = []string{pubkey}
|
||||||
|
}
|
||||||
|
|
||||||
|
events := sys.Pool.FetchMany(ctx, []string{relay}, filter)
|
||||||
|
|
||||||
|
result := strings.Builder{}
|
||||||
|
for ie := range events {
|
||||||
|
result.WriteString("author public key: ")
|
||||||
|
result.WriteString(ie.PubKey)
|
||||||
|
result.WriteString("content: '")
|
||||||
|
result.WriteString(ie.Content)
|
||||||
|
result.WriteString("'")
|
||||||
|
result.WriteString("\n---\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result.String()), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return server.ServeStdio(s)
|
||||||
|
},
|
||||||
|
}
|
||||||
355
musig2.go
Normal file
355
musig2.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getMusigAggregatedKey(_ context.Context, keys []string) (string, error) {
|
||||||
|
knownSigners := make([]*btcec.PublicKey, len(keys))
|
||||||
|
for i, spk := range keys {
|
||||||
|
bpk, err := hex.DecodeString(spk)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("'%s' is invalid hex: %w", spk, err)
|
||||||
|
}
|
||||||
|
if len(bpk) == 32 {
|
||||||
|
return "", fmt.Errorf("'%s' is missing the leading parity byte", spk)
|
||||||
|
}
|
||||||
|
pk, err := btcec.ParsePubKey(bpk)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
|
||||||
|
}
|
||||||
|
knownSigners[i] = pk
|
||||||
|
}
|
||||||
|
|
||||||
|
aggpk, _, _, err := musig2.AggregateKeys(knownSigners, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("aggregation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(aggpk.FinalKey.SerializeCompressed()[1:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func performMusig(
|
||||||
|
_ context.Context,
|
||||||
|
sec string,
|
||||||
|
evt *nostr.Event,
|
||||||
|
numSigners int,
|
||||||
|
keys []string,
|
||||||
|
nonces []string,
|
||||||
|
secNonce string,
|
||||||
|
partialSigs []string,
|
||||||
|
) (signed bool, err error) {
|
||||||
|
// preprocess data received
|
||||||
|
secb, err := hex.DecodeString(sec)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
seck, pubk := btcec.PrivKeyFromBytes(secb)
|
||||||
|
|
||||||
|
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
|
||||||
|
includesUs := false
|
||||||
|
for _, hexpub := range keys {
|
||||||
|
bpub, err := hex.DecodeString(hexpub)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
spub, err := btcec.ParsePubKey(bpub)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
knownSigners = append(knownSigners, spub)
|
||||||
|
|
||||||
|
if spub.IsEqual(pubk) {
|
||||||
|
includesUs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !includesUs {
|
||||||
|
knownSigners = append(knownSigners, pubk)
|
||||||
|
}
|
||||||
|
|
||||||
|
knownNonces := make([][66]byte, 0, numSigners)
|
||||||
|
for _, hexnonce := range nonces {
|
||||||
|
bnonce, err := hex.DecodeString(hexnonce)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(bnonce) != 66 {
|
||||||
|
return false, fmt.Errorf("nonce is not 66 bytes: %s", hexnonce)
|
||||||
|
}
|
||||||
|
var b66nonce [66]byte
|
||||||
|
copy(b66nonce[:], bnonce)
|
||||||
|
knownNonces = append(knownNonces, b66nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
knownPartialSigs := make([]*musig2.PartialSignature, 0, numSigners)
|
||||||
|
for _, hexps := range partialSigs {
|
||||||
|
bps, err := hex.DecodeString(hexps)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var ps musig2.PartialSignature
|
||||||
|
if err := ps.Decode(bytes.NewBuffer(bps)); err != nil {
|
||||||
|
return false, fmt.Errorf("invalid partial signature %s: %w", hexps, err)
|
||||||
|
}
|
||||||
|
knownPartialSigs = append(knownPartialSigs, &ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the context
|
||||||
|
var mctx *musig2.Context
|
||||||
|
if len(knownSigners) < numSigners {
|
||||||
|
// we don't know all the signers yet
|
||||||
|
mctx, err = musig2.NewContext(seck, true,
|
||||||
|
musig2.WithNumSigners(numSigners),
|
||||||
|
musig2.WithEarlyNonceGen(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create signing context with %d unknown signers: %w",
|
||||||
|
numSigners, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// we know all the signers
|
||||||
|
mctx, err = musig2.NewContext(seck, true,
|
||||||
|
musig2.WithKnownSigners(knownSigners),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create signing context with %d known signers: %w",
|
||||||
|
len(knownSigners), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonce generation phase -- for sharing
|
||||||
|
if len(knownSigners) < numSigners {
|
||||||
|
// if we don't have all the signers we just generate a nonce and yield it to the next people
|
||||||
|
nonce, err := mctx.EarlySessionNonce()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
log("the following code should be saved secretly until the next step an included with --musig-nonce-secret:\n")
|
||||||
|
log("%s\n\n", base64.StdEncoding.EncodeToString(nonce.SecNonce[:]))
|
||||||
|
|
||||||
|
knownNonces = append(knownNonces, nonce.PubNonce)
|
||||||
|
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, nil, false)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we got here we have all the pubkeys, so we can print the combined key
|
||||||
|
if comb, err := mctx.CombinedKey(); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to combine keys (after %d signers): %w", len(knownSigners), err)
|
||||||
|
} else {
|
||||||
|
evt.PubKey = hex.EncodeToString(comb.SerializeCompressed()[1:])
|
||||||
|
evt.ID = evt.GetID()
|
||||||
|
log("combined key: %x\n\n", comb.SerializeCompressed())
|
||||||
|
}
|
||||||
|
|
||||||
|
// we have all the signers, which means we must also have all the nonces
|
||||||
|
var session *musig2.Session
|
||||||
|
if len(keys) == numSigners-1 {
|
||||||
|
// if we were the last to include our key, that means we have to include our nonce here to
|
||||||
|
// i.e. we didn't input our own pub nonce in the parameters
|
||||||
|
session, err = mctx.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create session as the last peer to include our key: %w", err)
|
||||||
|
}
|
||||||
|
knownNonces = append(knownNonces, session.PublicNonce())
|
||||||
|
} else {
|
||||||
|
// otherwise we have included our own nonce in the parameters (from copypasting) but must
|
||||||
|
// also include the secret nonce that wasn't shared with peers
|
||||||
|
if secNonce == "" {
|
||||||
|
return false, fmt.Errorf("missing --musig-nonce-secret value")
|
||||||
|
}
|
||||||
|
secNonceB, err := base64.StdEncoding.DecodeString(secNonce)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("invalid --musig-nonce-secret: %w", err)
|
||||||
|
}
|
||||||
|
var secNonce97 [97]byte
|
||||||
|
copy(secNonce97[:], secNonceB)
|
||||||
|
session, err = mctx.NewSession(musig2.WithPreGeneratedNonce(&musig2.Nonces{
|
||||||
|
SecNonce: secNonce97,
|
||||||
|
PubNonce: secNonceToPubNonce(secNonce97),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create signing session with secret nonce: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var noncesOk bool
|
||||||
|
for _, b66nonce := range knownNonces {
|
||||||
|
if b66nonce == session.PublicNonce() {
|
||||||
|
// don't add our own nonce
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
noncesOk, err = session.RegisterPubNonce(b66nonce)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to register nonce: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !noncesOk {
|
||||||
|
return false, fmt.Errorf("we've registered all the nonces we had but at least one is missing, this shouldn't happen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// signing phase
|
||||||
|
// we always have to sign, so let's do this
|
||||||
|
id := evt.GetID()
|
||||||
|
hash, _ := hex.DecodeString(id)
|
||||||
|
var msg32 [32]byte
|
||||||
|
copy(msg32[:], hash)
|
||||||
|
partialSig, err := session.Sign(msg32) // this will already include our sig in the bundle
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to produce partial signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(knownPartialSigs)+1 < len(knownSigners) {
|
||||||
|
// still missing some signatures
|
||||||
|
knownPartialSigs = append(knownPartialSigs, partialSig) // we include ours here just so it's printed
|
||||||
|
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, knownPartialSigs, true)
|
||||||
|
return false, nil
|
||||||
|
} else {
|
||||||
|
// we have all signatures
|
||||||
|
for _, ps := range knownPartialSigs {
|
||||||
|
_, err = session.CombineSig(ps)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to combine partial signature: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we have the signature
|
||||||
|
evt.Sig = hex.EncodeToString(session.FinalSig().Serialize())
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPublicCommandForNextPeer(
|
||||||
|
evt *nostr.Event,
|
||||||
|
numSigners int,
|
||||||
|
knownSigners []*btcec.PublicKey,
|
||||||
|
knownNonces [][66]byte,
|
||||||
|
knownPartialSigs []*musig2.PartialSignature,
|
||||||
|
includeNonceSecret bool,
|
||||||
|
) {
|
||||||
|
maybeNonceSecret := ""
|
||||||
|
if includeNonceSecret {
|
||||||
|
maybeNonceSecret = " --musig-nonce-secret '<insert-nonce-secret>'"
|
||||||
|
}
|
||||||
|
|
||||||
|
log("the next signer and they should call this on their side:\nnak event --sec <insert-secret-key> --musig %d %s%s%s%s%s\n",
|
||||||
|
numSigners,
|
||||||
|
eventToCliArgs(evt),
|
||||||
|
signersToCliArgs(knownSigners),
|
||||||
|
noncesToCliArgs(knownNonces),
|
||||||
|
partialSigsToCliArgs(knownPartialSigs),
|
||||||
|
maybeNonceSecret,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventToCliArgs(evt *nostr.Event) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.Grow(100)
|
||||||
|
|
||||||
|
b.WriteString("-k ")
|
||||||
|
b.WriteString(strconv.Itoa(evt.Kind))
|
||||||
|
|
||||||
|
b.WriteString(" -ts ")
|
||||||
|
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
|
||||||
|
|
||||||
|
b.WriteString(" -c '")
|
||||||
|
b.WriteString(evt.Content)
|
||||||
|
b.WriteString("'")
|
||||||
|
|
||||||
|
for _, tag := range evt.Tags {
|
||||||
|
b.WriteString(" -t '")
|
||||||
|
b.WriteString(tag.Key())
|
||||||
|
if len(tag) > 1 {
|
||||||
|
b.WriteString("=")
|
||||||
|
b.WriteString(tag[1])
|
||||||
|
if len(tag) > 2 {
|
||||||
|
for _, item := range tag[2:] {
|
||||||
|
b.WriteString(";")
|
||||||
|
b.WriteString(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signersToCliArgs(knownSigners []*btcec.PublicKey) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.Grow(len(knownSigners) * (16 + 66))
|
||||||
|
|
||||||
|
for _, signerPub := range knownSigners {
|
||||||
|
b.WriteString(" --musig-pubkey ")
|
||||||
|
b.WriteString(hex.EncodeToString(signerPub.SerializeCompressed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func noncesToCliArgs(knownNonces [][66]byte) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.Grow(len(knownNonces) * (15 + 132))
|
||||||
|
|
||||||
|
for _, nonce := range knownNonces {
|
||||||
|
b.WriteString(" --musig-nonce ")
|
||||||
|
b.WriteString(hex.EncodeToString(nonce[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func partialSigsToCliArgs(knownPartialSigs []*musig2.PartialSignature) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.Grow(len(knownPartialSigs) * (17 + 64))
|
||||||
|
|
||||||
|
for _, partialSig := range knownPartialSigs {
|
||||||
|
b.WriteString(" --musig-partial ")
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
partialSig.Encode(w)
|
||||||
|
b.Write([]byte(hex.EncodeToString(w.Bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// this function is copied from btcec because it's not exported for some reason
|
||||||
|
func secNonceToPubNonce(secNonce [musig2.SecNonceSize]byte) [musig2.PubNonceSize]byte {
|
||||||
|
var k1Mod, k2Mod btcec.ModNScalar
|
||||||
|
k1Mod.SetByteSlice(secNonce[:btcec.PrivKeyBytesLen])
|
||||||
|
k2Mod.SetByteSlice(secNonce[btcec.PrivKeyBytesLen:])
|
||||||
|
|
||||||
|
var r1, r2 btcec.JacobianPoint
|
||||||
|
btcec.ScalarBaseMultNonConst(&k1Mod, &r1)
|
||||||
|
btcec.ScalarBaseMultNonConst(&k2Mod, &r2)
|
||||||
|
|
||||||
|
// Next, we'll convert the key in jacobian format to a normal public
|
||||||
|
// key expressed in affine coordinates.
|
||||||
|
r1.ToAffine()
|
||||||
|
r2.ToAffine()
|
||||||
|
r1Pub := btcec.NewPublicKey(&r1.X, &r1.Y)
|
||||||
|
r2Pub := btcec.NewPublicKey(&r2.X, &r2.Y)
|
||||||
|
|
||||||
|
var pubNonce [musig2.PubNonceSize]byte
|
||||||
|
|
||||||
|
// The public nonces are serialized as: R1 || R2, where both keys are
|
||||||
|
// serialized in compressed format.
|
||||||
|
copy(pubNonce[:], r1Pub.SerializeCompressed())
|
||||||
|
copy(
|
||||||
|
pubNonce[btcec.PubKeyBytesLenCompressed:],
|
||||||
|
r2Pub.SerializeCompressed(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return pubNonce
|
||||||
|
}
|
||||||
56
nostrfs/asyncfile.go
Normal file
56
nostrfs/asyncfile.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AsyncFile struct {
|
||||||
|
fs.Inode
|
||||||
|
ctx context.Context
|
||||||
|
fetched atomic.Bool
|
||||||
|
data []byte
|
||||||
|
ts nostr.Timestamp
|
||||||
|
load func() ([]byte, nostr.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*AsyncFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
if af.fetched.CompareAndSwap(false, true) {
|
||||||
|
af.data, af.ts = af.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Size = uint64(len(af.data))
|
||||||
|
out.Mtime = uint64(af.ts)
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
||||||
|
if af.fetched.CompareAndSwap(false, true) {
|
||||||
|
af.data, af.ts = af.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (af *AsyncFile) Read(
|
||||||
|
ctx context.Context,
|
||||||
|
f fs.FileHandle,
|
||||||
|
dest []byte,
|
||||||
|
off int64,
|
||||||
|
) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(af.data) {
|
||||||
|
end = len(af.data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(af.data[off:end]), 0
|
||||||
|
}
|
||||||
50
nostrfs/deterministicfile.go
Normal file
50
nostrfs/deterministicfile.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeterministicFile struct {
|
||||||
|
fs.Inode
|
||||||
|
get func() (ctime, mtime uint64, data string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
|
||||||
|
_ = (fs.NodeReader)((*DeterministicFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
|
||||||
|
return &DeterministicFile{
|
||||||
|
get: get,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
var content string
|
||||||
|
out.Mode = 0444
|
||||||
|
out.Ctime, out.Mtime, content = f.get()
|
||||||
|
out.Size = uint64(len(content))
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
_, _, content := f.get()
|
||||||
|
data := unsafe.Slice(unsafe.StringData(content), len(content))
|
||||||
|
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(data[off:end]), fs.OK
|
||||||
|
}
|
||||||
400
nostrfs/entitydir.go
Normal file
400
nostrfs/entitydir.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"fiatjaf.com/lib/debouncer"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip27"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip92"
|
||||||
|
sdk "github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntityDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
|
||||||
|
publisher *debouncer.Debouncer
|
||||||
|
event *nostr.Event
|
||||||
|
updating struct {
|
||||||
|
title string
|
||||||
|
content string
|
||||||
|
publishedAt uint64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeCreater)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
out.Ctime = uint64(e.event.CreatedAt)
|
||||||
|
if e.updating.publishedAt != 0 {
|
||||||
|
out.Mtime = e.updating.publishedAt
|
||||||
|
} else {
|
||||||
|
out.Mtime = e.PublishedAt()
|
||||||
|
}
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Create(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
flags uint32,
|
||||||
|
mode uint32,
|
||||||
|
out *fuse.EntryOut,
|
||||||
|
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
if name == "publish" && e.publisher.IsRunning() {
|
||||||
|
// this causes the publish process to be triggered faster
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing now!\n")
|
||||||
|
e.publisher.Flush()
|
||||||
|
return nil, nil, 0, syscall.ENOTDIR
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||||
|
switch name {
|
||||||
|
case "content" + kindToExtension(e.event.Kind):
|
||||||
|
e.updating.content = e.event.Content
|
||||||
|
return syscall.ENOTDIR
|
||||||
|
case "title":
|
||||||
|
e.updating.title = e.Title()
|
||||||
|
return syscall.ENOTDIR
|
||||||
|
default:
|
||||||
|
return syscall.EINTR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
e.updating.publishedAt = in.Mtime
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) OnAdd(_ context.Context) {
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
npub, _ := nip19.EncodePublicKey(e.event.PubKey)
|
||||||
|
e.AddChild("@author", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(e.root.wd + "/" + npub),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("event.json", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
eventj, _ := json.MarshalIndent(e.event, "", " ")
|
||||||
|
return uint64(e.event.CreatedAt),
|
||||||
|
uint64(e.event.CreatedAt),
|
||||||
|
unsafe.String(unsafe.SliceData(eventj), len(eventj))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("identifier", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(e.event.Tags.GetD()),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(e.event.CreatedAt),
|
||||||
|
Mtime: uint64(e.event.CreatedAt),
|
||||||
|
Size: uint64(len(e.event.Tags.GetD())),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
|
||||||
|
// read-only
|
||||||
|
e.AddChild("title", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
} else {
|
||||||
|
// writeable
|
||||||
|
e.updating.title = e.Title()
|
||||||
|
e.updating.publishedAt = e.PublishedAt()
|
||||||
|
e.updating.content = e.event.Content
|
||||||
|
|
||||||
|
e.AddChild("title", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||||
|
log("title updated")
|
||||||
|
e.updating.title = strings.TrimSpace(s)
|
||||||
|
e.handleWrite()
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||||
|
log("content updated")
|
||||||
|
e.updating.content = strings.TrimSpace(s)
|
||||||
|
e.handleWrite()
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refsdir *fs.Inode
|
||||||
|
i := 0
|
||||||
|
for ref := range nip27.ParseReferences(*e.event) {
|
||||||
|
i++
|
||||||
|
if refsdir == nil {
|
||||||
|
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
e.root.AddChild("references", refsdir, true)
|
||||||
|
}
|
||||||
|
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imagesdir *fs.Inode
|
||||||
|
addImage := func(url string) {
|
||||||
|
if imagesdir == nil {
|
||||||
|
in := &fs.Inode{}
|
||||||
|
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
e.AddChild("images", imagesdir, true)
|
||||||
|
}
|
||||||
|
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&AsyncFile{
|
||||||
|
ctx: e.root.ctx,
|
||||||
|
load: func() ([]byte, nostr.Timestamp) {
|
||||||
|
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
return w.Bytes(), 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
images := nip92.ParseTags(e.event.Tags)
|
||||||
|
for _, imeta := range images {
|
||||||
|
if imeta.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addImage(imeta.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag := e.event.Tags.Find("image"); tag != nil {
|
||||||
|
addImage(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) IsNew() bool {
|
||||||
|
return e.event.CreatedAt == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) PublishedAt() uint64 {
|
||||||
|
if tag := e.event.Tags.Find("published_at"); tag != nil {
|
||||||
|
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
|
||||||
|
return publishedAt
|
||||||
|
}
|
||||||
|
return uint64(e.event.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Title() string {
|
||||||
|
if tag := e.event.Tags.Find("title"); tag != nil {
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) handleWrite() {
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
|
||||||
|
if e.publisher.IsRunning() {
|
||||||
|
log(", timer reset")
|
||||||
|
}
|
||||||
|
log(", publishing the ")
|
||||||
|
if e.IsNew() {
|
||||||
|
log("new")
|
||||||
|
} else {
|
||||||
|
log("updated")
|
||||||
|
}
|
||||||
|
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
|
||||||
|
} else {
|
||||||
|
log(".\n")
|
||||||
|
}
|
||||||
|
if !e.publisher.IsRunning() {
|
||||||
|
log("- `touch publish` to publish immediately\n")
|
||||||
|
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
e.publisher.Call(func() {
|
||||||
|
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
|
||||||
|
log("not modified, publish canceled.\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: e.event.Kind,
|
||||||
|
Content: e.updating.content,
|
||||||
|
Tags: make(nostr.Tags, len(e.event.Tags)),
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
}
|
||||||
|
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
|
||||||
|
if e.updating.title != "" {
|
||||||
|
if titleTag := evt.Tags.Find("title"); titleTag != nil {
|
||||||
|
titleTag[1] = e.updating.title
|
||||||
|
} else {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "published_at" tag
|
||||||
|
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
|
||||||
|
if publishedAtStr != "0" {
|
||||||
|
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
|
||||||
|
publishedAtTag[1] = publishedAtStr
|
||||||
|
} else {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add "p" tags from people mentioned and "q" tags from events mentioned
|
||||||
|
for ref := range nip27.ParseReferences(evt) {
|
||||||
|
tag := ref.Pointer.AsTag()
|
||||||
|
key := tag[0]
|
||||||
|
val := tag[1]
|
||||||
|
if key == "e" || key == "a" {
|
||||||
|
key = "q"
|
||||||
|
}
|
||||||
|
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign and publish
|
||||||
|
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
|
||||||
|
log("failed to sign: '%s'.\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logverbose("%s\n", evt)
|
||||||
|
|
||||||
|
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey, 8)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
log("publishing to %d relays... ", len(relays))
|
||||||
|
success := false
|
||||||
|
first := true
|
||||||
|
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
log("%s: ok", color.GreenString(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
|
||||||
|
if success {
|
||||||
|
e.event = &evt
|
||||||
|
log("event updated locally.\n")
|
||||||
|
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
|
||||||
|
} else {
|
||||||
|
log("failed.\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) FetchAndCreateEntityDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
extension string,
|
||||||
|
pointer nostr.EntityPointer,
|
||||||
|
) (*fs.Inode, error) {
|
||||||
|
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.CreateEntityDir(parent, event), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateEntityDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
event *nostr.Event,
|
||||||
|
) *fs.Inode {
|
||||||
|
return parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
)
|
||||||
|
}
|
||||||
237
nostrfs/eventdir.go
Normal file
237
nostrfs/eventdir.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip10"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip22"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip27"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip73"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip92"
|
||||||
|
sdk "github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventDir struct {
|
||||||
|
fs.Inode
|
||||||
|
ctx context.Context
|
||||||
|
wd string
|
||||||
|
evt *nostr.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
|
||||||
|
|
||||||
|
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
out.Mtime = uint64(e.evt.CreatedAt)
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) FetchAndCreateEventDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
pointer nostr.EventPointer,
|
||||||
|
) (*fs.Inode, error) {
|
||||||
|
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.CreateEventDir(parent, event), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateEventDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
event *nostr.Event,
|
||||||
|
) *fs.Inode {
|
||||||
|
h := parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(event.ID)},
|
||||||
|
)
|
||||||
|
|
||||||
|
npub, _ := nip19.EncodePublicKey(event.PubKey)
|
||||||
|
h.AddChild("@author", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + npub),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
eventj, _ := json.MarshalIndent(event, "", " ")
|
||||||
|
h.AddChild("event.json", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: eventj,
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(len(event.Content)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
h.AddChild("id", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(event.ID),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
h.AddChild("content.txt", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(event.Content),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(len(event.Content)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
var refsdir *fs.Inode
|
||||||
|
i := 0
|
||||||
|
for ref := range nip27.ParseReferences(*event) {
|
||||||
|
i++
|
||||||
|
if refsdir == nil {
|
||||||
|
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
h.AddChild("references", refsdir, true)
|
||||||
|
}
|
||||||
|
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imagesdir *fs.Inode
|
||||||
|
images := nip92.ParseTags(event.Tags)
|
||||||
|
for _, imeta := range images {
|
||||||
|
if imeta.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if imagesdir == nil {
|
||||||
|
in := &fs.Inode{}
|
||||||
|
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
h.AddChild("images", imagesdir, true)
|
||||||
|
}
|
||||||
|
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&AsyncFile{
|
||||||
|
ctx: r.ctx,
|
||||||
|
load: func() ([]byte, nostr.Timestamp) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
return w.Bytes(), 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Kind == 1 {
|
||||||
|
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
|
||||||
|
nevent := nip19.EncodePointer(*pointer)
|
||||||
|
h.AddChild("@root", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
|
||||||
|
nevent := nip19.EncodePointer(*pointer)
|
||||||
|
h.AddChild("@parent", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
} else if event.Kind == 1111 {
|
||||||
|
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
|
||||||
|
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||||
|
h.AddChild("@root", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
} else {
|
||||||
|
nevent := nip19.EncodePointer(pointer)
|
||||||
|
h.AddChild("@parent", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
|
||||||
|
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||||
|
h.AddChild("@parent", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
} else {
|
||||||
|
nevent := nip19.EncodePointer(pointer)
|
||||||
|
h.AddChild("@parent", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
19
nostrfs/helpers.go
Normal file
19
nostrfs/helpers.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
func kindToExtension(kind int) string {
|
||||||
|
switch kind {
|
||||||
|
case 30023:
|
||||||
|
return "md"
|
||||||
|
case 30818:
|
||||||
|
return "adoc"
|
||||||
|
default:
|
||||||
|
return "txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToUint64(hexStr string) uint64 {
|
||||||
|
v, _ := strconv.ParseUint(hexStr[16:32], 16, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
260
nostrfs/npubdir.go
Normal file
260
nostrfs/npubdir.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/liamg/magic"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NpubDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
pointer nostr.ProfilePointer
|
||||||
|
fetched atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateNpubDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
pointer nostr.ProfilePointer,
|
||||||
|
signer nostr.Signer,
|
||||||
|
) *fs.Inode {
|
||||||
|
npubdir := &NpubDir{root: r, pointer: pointer}
|
||||||
|
return parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
npubdir,
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(pointer.PublicKey)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NpubDir) OnAdd(_ context.Context) {
|
||||||
|
log := h.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
|
||||||
|
log("- adding folder for %s with relays %s\n",
|
||||||
|
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
|
||||||
|
|
||||||
|
h.AddChild("pubkey", h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey + "\n"), Attr: fuse.Attr{Mode: 0444}},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
|
||||||
|
if pm.Event == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataj, _ := json.MarshalIndent(pm, "", " ")
|
||||||
|
h.AddChild(
|
||||||
|
"metadata.json",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: metadataj,
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mtime: uint64(pm.Event.CreatedAt),
|
||||||
|
Mode: 0444,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 300 {
|
||||||
|
b := &bytes.Buffer{}
|
||||||
|
io.Copy(b, resp.Body)
|
||||||
|
|
||||||
|
ext := "png"
|
||||||
|
if ft, err := magic.Lookup(b.Bytes()); err == nil {
|
||||||
|
ext = ft.Extension
|
||||||
|
}
|
||||||
|
|
||||||
|
h.AddChild("picture."+ext, h.NewPersistentInode(
|
||||||
|
ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: b.Bytes(),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mtime: uint64(pm.Event.CreatedAt),
|
||||||
|
Mode: 0444,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if h.GetChild("notes") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"notes",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{1},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("comments") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"comments",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{1111},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("photos") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"photos",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{20},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("videos") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"videos",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{21, 22},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("highlights") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"highlights",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{9802},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("articles") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"articles",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{30023},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: true,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("wiki") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"wiki",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []int{30818},
|
||||||
|
Authors: []string{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: true,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
nostrfs/root.go
Normal file
131
nostrfs/root.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip05"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19"
|
||||||
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
AutoPublishNotesTimeout time.Duration
|
||||||
|
AutoPublishArticlesTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type NostrRoot struct {
|
||||||
|
fs.Inode
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
wd string
|
||||||
|
sys *sdk.System
|
||||||
|
rootPubKey string
|
||||||
|
signer nostr.Signer
|
||||||
|
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
|
||||||
|
|
||||||
|
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
|
||||||
|
pubkey, _ := user.GetPublicKey(ctx)
|
||||||
|
abs, _ := filepath.Abs(mountpoint)
|
||||||
|
|
||||||
|
var signer nostr.Signer
|
||||||
|
if user != nil {
|
||||||
|
signer, _ = user.(nostr.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &NostrRoot{
|
||||||
|
ctx: ctx,
|
||||||
|
sys: sys,
|
||||||
|
rootPubKey: pubkey,
|
||||||
|
signer: signer,
|
||||||
|
wd: abs,
|
||||||
|
|
||||||
|
opts: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) OnAdd(_ context.Context) {
|
||||||
|
if r.rootPubKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
|
|
||||||
|
// add our contacts
|
||||||
|
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
|
||||||
|
for _, f := range fl.Items {
|
||||||
|
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
|
||||||
|
npub, _ := nip19.EncodePublicKey(f.Pubkey)
|
||||||
|
r.AddChild(
|
||||||
|
npub,
|
||||||
|
r.CreateNpubDir(r, pointer, nil),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add ourselves
|
||||||
|
npub, _ := nip19.EncodePublicKey(r.rootPubKey)
|
||||||
|
if r.GetChild(npub) == nil {
|
||||||
|
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
|
||||||
|
|
||||||
|
r.AddChild(
|
||||||
|
npub,
|
||||||
|
r.CreateNpubDir(r, pointer, r.signer),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a link to ourselves
|
||||||
|
r.AddChild("@me", r.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||||
|
out.SetEntryTimeout(time.Minute * 5)
|
||||||
|
|
||||||
|
child := r.GetChild(name)
|
||||||
|
if child != nil {
|
||||||
|
return child, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
|
||||||
|
return r.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer, err := nip19.ToPointer(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p := pointer.(type) {
|
||||||
|
case nostr.ProfilePointer:
|
||||||
|
npubdir := r.CreateNpubDir(r, p, nil)
|
||||||
|
return npubdir, fs.OK
|
||||||
|
case nostr.EventPointer:
|
||||||
|
eventdir, err := r.FetchAndCreateEventDir(r, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
return eventdir, fs.OK
|
||||||
|
default:
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
}
|
||||||
286
nostrfs/viewdir.go
Normal file
286
nostrfs/viewdir.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"fiatjaf.com/lib/debouncer"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip27"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ViewDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
fetched atomic.Bool
|
||||||
|
filter nostr.Filter
|
||||||
|
paginate bool
|
||||||
|
relays []string
|
||||||
|
replaceable bool
|
||||||
|
createable bool
|
||||||
|
publisher *debouncer.Debouncer
|
||||||
|
publishing struct {
|
||||||
|
note string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeCreater)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Create(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
flags uint32,
|
||||||
|
mode uint32,
|
||||||
|
out *fuse.EntryOut,
|
||||||
|
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return nil, nil, 0, syscall.EPERM
|
||||||
|
}
|
||||||
|
if n.publisher == nil {
|
||||||
|
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||||
|
}
|
||||||
|
if n.filter.Kinds[0] != 1 {
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "new":
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
if n.publisher.IsRunning() {
|
||||||
|
log("pending note updated, timer reset.")
|
||||||
|
} else {
|
||||||
|
log("new note detected")
|
||||||
|
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
|
||||||
|
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
|
||||||
|
} else {
|
||||||
|
log(".\n")
|
||||||
|
}
|
||||||
|
log("- `touch publish` to publish immediately\n")
|
||||||
|
log("- `rm new` to erase and cancel the publication.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
n.publisher.Call(n.publishNote)
|
||||||
|
|
||||||
|
first := true
|
||||||
|
|
||||||
|
return n.NewPersistentInode(
|
||||||
|
n.root.ctx,
|
||||||
|
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
|
||||||
|
if !first {
|
||||||
|
log("pending note updated, timer reset.\n")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
n.publishing.note = strings.TrimSpace(s)
|
||||||
|
n.publisher.Call(n.publishNote)
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), nil, 0, fs.OK
|
||||||
|
case "publish":
|
||||||
|
if n.publisher.IsRunning() {
|
||||||
|
// this causes the publish process to be triggered faster
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing now!\n")
|
||||||
|
n.publisher.Flush()
|
||||||
|
return nil, nil, 0, syscall.ENOTDIR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||||
|
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return syscall.EPERM
|
||||||
|
}
|
||||||
|
if n.publisher == nil {
|
||||||
|
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||||
|
}
|
||||||
|
if n.filter.Kinds[0] != 1 {
|
||||||
|
return syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "new":
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing canceled.\n")
|
||||||
|
n.publisher.Stop()
|
||||||
|
n.publishing.note = ""
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
return syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) publishNote() {
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
log("publishing note...\n")
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: 1,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: n.publishing.note,
|
||||||
|
Tags: make(nostr.Tags, 0, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
// our write relays
|
||||||
|
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey, 8)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add "p" tags from people mentioned and "q" tags from events mentioned
|
||||||
|
for ref := range nip27.ParseReferences(evt) {
|
||||||
|
tag := ref.Pointer.AsTag()
|
||||||
|
key := tag[0]
|
||||||
|
val := tag[1]
|
||||||
|
if key == "e" || key == "a" {
|
||||||
|
key = "q"
|
||||||
|
}
|
||||||
|
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
|
||||||
|
// add their "read" relays
|
||||||
|
if key == "p" {
|
||||||
|
for _, r := range n.root.sys.FetchInboxRelays(n.root.ctx, val, 4) {
|
||||||
|
if !slices.Contains(relays, r) {
|
||||||
|
relays = append(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign and publish
|
||||||
|
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
|
||||||
|
log("failed to sign: %s\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log(evt.String() + "\n")
|
||||||
|
|
||||||
|
log("publishing to %d relays... ", len(relays))
|
||||||
|
success := false
|
||||||
|
first := true
|
||||||
|
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
log("%s: ok", color.GreenString(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
|
||||||
|
if success {
|
||||||
|
n.RmChild("new")
|
||||||
|
n.AddChild(evt.ID, n.root.CreateEventDir(n, &evt), true)
|
||||||
|
log("event published as %s and updated locally.\n", color.BlueString(evt.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
now := nostr.Now()
|
||||||
|
if n.filter.Until != nil {
|
||||||
|
now = *n.filter.Until
|
||||||
|
}
|
||||||
|
aMonthAgo := now - 30*24*60*60
|
||||||
|
out.Mtime = uint64(aMonthAgo)
|
||||||
|
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
|
||||||
|
if n.fetched.CompareAndSwap(true, true) {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.paginate {
|
||||||
|
now := nostr.Now()
|
||||||
|
if n.filter.Until != nil {
|
||||||
|
now = *n.filter.Until
|
||||||
|
}
|
||||||
|
aMonthAgo := now - 30*24*60*60
|
||||||
|
n.filter.Since = &aMonthAgo
|
||||||
|
|
||||||
|
filter := n.filter
|
||||||
|
filter.Until = &aMonthAgo
|
||||||
|
|
||||||
|
n.AddChild("@previous", n.NewPersistentInode(
|
||||||
|
n.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: n.root,
|
||||||
|
filter: filter,
|
||||||
|
relays: n.relays,
|
||||||
|
replaceable: n.replaceable,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replaceable {
|
||||||
|
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter,
|
||||||
|
nostr.WithLabel("nakfs"),
|
||||||
|
).Range {
|
||||||
|
name := rkey.D
|
||||||
|
if name == "" {
|
||||||
|
name = "_"
|
||||||
|
}
|
||||||
|
if n.GetChild(name) == nil {
|
||||||
|
n.AddChild(name, n.root.CreateEntityDir(n, evt), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
|
||||||
|
nostr.WithLabel("nakfs"),
|
||||||
|
) {
|
||||||
|
if n.GetChild(ie.Event.ID) == nil {
|
||||||
|
n.AddChild(ie.Event.ID, n.root.CreateEventDir(n, ie.Event), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||||
|
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return nil, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replaceable {
|
||||||
|
// create a template event that can later be modified and published as new
|
||||||
|
return n.root.CreateEntityDir(n, &nostr.Event{
|
||||||
|
PubKey: n.root.rootPubKey,
|
||||||
|
CreatedAt: 0,
|
||||||
|
Kind: n.filter.Kinds[0],
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
nostr.Tag{"d", name},
|
||||||
|
},
|
||||||
|
}), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, syscall.ENOTSUP
|
||||||
|
}
|
||||||
93
nostrfs/writeablefile.go
Normal file
93
nostrfs/writeablefile.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WriteableFile struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
mu sync.Mutex
|
||||||
|
data []byte
|
||||||
|
attr fuse.Attr
|
||||||
|
onWrite func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeReader)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeWriter)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
|
||||||
|
return &WriteableFile{
|
||||||
|
root: r,
|
||||||
|
data: []byte(data),
|
||||||
|
attr: fuse.Attr{
|
||||||
|
Mode: 0666,
|
||||||
|
Ctime: ctime,
|
||||||
|
Mtime: mtime,
|
||||||
|
Size: uint64(len(data)),
|
||||||
|
},
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
offset := int(off)
|
||||||
|
end := offset + len(data)
|
||||||
|
if len(f.data) < end {
|
||||||
|
newData := make([]byte, offset+len(data))
|
||||||
|
copy(newData, f.data)
|
||||||
|
f.data = newData
|
||||||
|
}
|
||||||
|
copy(f.data[offset:], data)
|
||||||
|
f.data = f.data[0:end]
|
||||||
|
|
||||||
|
f.onWrite(string(f.data))
|
||||||
|
return uint32(len(data)), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out.Attr = f.attr
|
||||||
|
out.Attr.Size = uint64(len(f.data))
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
f.attr.Mtime = in.Mtime
|
||||||
|
f.attr.Atime = in.Atime
|
||||||
|
f.attr.Ctime = in.Ctime
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(f.data) {
|
||||||
|
end = len(f.data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(f.data[off:end]), fs.OK
|
||||||
|
}
|
||||||
68
outbox.go
Normal file
68
outbox.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var outbox = &cli.Command{
|
||||||
|
Name: "outbox",
|
||||||
|
Usage: "manage outbox relay hints database",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "init",
|
||||||
|
Usage: "initialize the outbox hints database",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
if hintsFileExists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if hintsFilePath == "" {
|
||||||
|
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(hintsFilePath), 0777); err == nil {
|
||||||
|
if err := os.WriteFile(hintsFilePath, []byte("{}"), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to create hints database: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("initialized hints database at %s\n", hintsFilePath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "list outbox relays for a given pubkey",
|
||||||
|
ArgsUsage: "<pubkey>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
if !hintsFileExists {
|
||||||
|
log("running with temporary fragile data.\n")
|
||||||
|
log("call `nak outbox init` to setup persistence.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Args().Len() != 1 {
|
||||||
|
return fmt.Errorf("expected exactly one argument (pubkey)")
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkey := c.Args().First()
|
||||||
|
if !nostr.IsValidPublicKey(pubkey) {
|
||||||
|
return fmt.Errorf("invalid public key: %s", pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relay := range sys.FetchOutboxRelays(ctx, pubkey, 6) {
|
||||||
|
stdout(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
212
relay.go
212
relay.go
@@ -1,37 +1,207 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip11"
|
"github.com/nbd-wtf/go-nostr/nip11"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/nbd-wtf/go-nostr/nip86"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var relay = &cli.Command{
|
var relay = &cli.Command{
|
||||||
Name: "relay",
|
Name: "relay",
|
||||||
Usage: "gets the relay information document for the given relay, as JSON",
|
Usage: "gets the relay information document for the given relay, as JSON -- or allows usage of the relay management API.",
|
||||||
Description: `example:
|
Description: `examples:
|
||||||
nak relay nostr.wine`,
|
fetching relay information:
|
||||||
ArgsUsage: "<relay-url>",
|
nak relay nostr.wine
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
url := c.Args().First()
|
|
||||||
if url == "" {
|
|
||||||
return fmt.Errorf("specify the <relay-url>")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(url, "wss://") && !strings.HasPrefix(url, "ws://") {
|
managing a relay
|
||||||
url = "wss://" + url
|
nak relay nostr.wine banevent --sec 1234 --id 037eb3751073770ff17483b1b1ff125866cd5147668271975ef0a8a8e7ee184a --reason "I don't like it"`,
|
||||||
}
|
ArgsUsage: "<relay-url>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for url := range getStdinLinesOrArguments(c.Args()) {
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("specify the <relay-url>")
|
||||||
|
}
|
||||||
|
|
||||||
info, err := nip11.Fetch(c.Context, url)
|
info, err := nip11.Fetch(ctx, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch '%s' information document: %w", url, err)
|
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
pretty, _ := json.MarshalIndent(info, "", " ")
|
pretty, _ := json.MarshalIndent(info, "", " ")
|
||||||
stdout(string(pretty))
|
stdout(string(pretty))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
Commands: (func() []*cli.Command {
|
||||||
|
methods := []struct {
|
||||||
|
method string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{"allowpubkey", []string{"pubkey", "reason"}},
|
||||||
|
{"banpubkey", []string{"pubkey", "reason"}},
|
||||||
|
{"listallowedpubkeys", nil},
|
||||||
|
{"allowpubkey", []string{"pubkey", "reason"}},
|
||||||
|
{"listallowedpubkeys", nil},
|
||||||
|
{"listeventsneedingmoderation", nil},
|
||||||
|
{"allowevent", []string{"id", "reason"}},
|
||||||
|
{"banevent", []string{"id", "reason"}},
|
||||||
|
{"listbannedevents", nil},
|
||||||
|
{"changerelayname", []string{"name"}},
|
||||||
|
{"changerelaydescription", []string{"description"}},
|
||||||
|
{"changerelayicon", []string{"icon"}},
|
||||||
|
{"allowkind", []string{"kind"}},
|
||||||
|
{"disallowkind", []string{"kind"}},
|
||||||
|
{"listallowedkinds", nil},
|
||||||
|
{"blockip", []string{"ip", "reason"}},
|
||||||
|
{"unblockip", []string{"ip", "reason"}},
|
||||||
|
{"listblockedips", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
commands := make([]*cli.Command, 0, len(methods))
|
||||||
|
for _, def := range methods {
|
||||||
|
def := def
|
||||||
|
|
||||||
|
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
|
||||||
|
for i, argName := range def.args {
|
||||||
|
flags[i] = declareFlag(argName)
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = append(flags, defaultKeyFlags...)
|
||||||
|
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Name: def.method,
|
||||||
|
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
|
||||||
|
Flags: flags,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
params := make([]any, len(def.args))
|
||||||
|
for i, argName := range def.args {
|
||||||
|
params[i] = getArgument(c, argName)
|
||||||
|
}
|
||||||
|
req := nip86.Request{Method: def.method, Params: params}
|
||||||
|
reqj, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
relayUrls := c.Args().Slice()
|
||||||
|
if len(relayUrls) == 0 {
|
||||||
|
stdout(string(reqj))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relayUrl := range relayUrls {
|
||||||
|
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
|
||||||
|
log("calling '%s' on %s... ", def.method, httpUrl)
|
||||||
|
body := bytes.NewBuffer(nil)
|
||||||
|
body.Write(reqj)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
payloadHash := sha256.Sum256(reqj)
|
||||||
|
tokenEvent := nostr.Event{
|
||||||
|
Kind: 27235,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"u", httpUrl},
|
||||||
|
{"method", "POST"},
|
||||||
|
{"payload", hex.EncodeToString(payloadHash[:])},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign token event: %w", err)
|
||||||
|
}
|
||||||
|
evtj, _ := json.Marshal(tokenEvent)
|
||||||
|
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
|
||||||
|
|
||||||
|
// Content-Type
|
||||||
|
req.Header.Set("Content-Type", "application/nostr+json+rpc")
|
||||||
|
|
||||||
|
// make request to relay
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log("failed: %s\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log("failed to read response: %s\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
log("failed with status %d\n", resp.StatusCode)
|
||||||
|
bodyPrintable := string(b)
|
||||||
|
if len(bodyPrintable) > 300 {
|
||||||
|
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||||
|
}
|
||||||
|
log(bodyPrintable)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var response nip86.Response
|
||||||
|
if err := json.Unmarshal(b, &response); err != nil {
|
||||||
|
log("bad json response: %s\n", err)
|
||||||
|
bodyPrintable := string(b)
|
||||||
|
if len(bodyPrintable) > 300 {
|
||||||
|
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||||
|
}
|
||||||
|
log(bodyPrintable)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// print the result
|
||||||
|
log("\n")
|
||||||
|
pretty, _ := json.MarshalIndent(response, "", " ")
|
||||||
|
stdout(string(pretty))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands
|
||||||
|
})(),
|
||||||
|
}
|
||||||
|
|
||||||
|
func declareFlag(argName string) cli.Flag {
|
||||||
|
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
|
||||||
|
switch argName {
|
||||||
|
case "kind":
|
||||||
|
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
|
||||||
|
case "reason":
|
||||||
|
return &cli.StringFlag{Name: argName, Usage: usage}
|
||||||
|
default:
|
||||||
|
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArgument(c *cli.Command, argName string) any {
|
||||||
|
switch argName {
|
||||||
|
case "kind":
|
||||||
|
return c.Int(argName)
|
||||||
|
default:
|
||||||
|
return c.String(argName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
417
req.go
417
req.go
@@ -1,23 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/mailru/easyjson"
|
"github.com/mailru/easyjson"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/nbd-wtf/go-nostr/nip77"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
|
const (
|
||||||
|
CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
|
||||||
|
// CATEGORY_SIGNER = "SIGNER OPTIONS" -- defined at event.go as the same (yes, I know)
|
||||||
|
)
|
||||||
|
|
||||||
var req = &cli.Command{
|
var req = &cli.Command{
|
||||||
Name: "req",
|
Name: "req",
|
||||||
Usage: "generates encoded REQ messages and optionally use them to talk to relays",
|
Usage: "generates encoded REQ messages and optionally use them to talk to relays",
|
||||||
Description: `outputs a NIP-01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
|
Description: `outputs a nip01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
|
||||||
|
|
||||||
example:
|
example:
|
||||||
nak req -k 1 -l 15 wss://nostr.wine wss://nostr-pub.wellorder.net
|
nak req -k 1 -l 15 wss://nostr.wine wss://nostr-pub.wellorder.net
|
||||||
@@ -27,111 +31,75 @@ it can also take a filter from stdin, optionally modify it with flags and send i
|
|||||||
|
|
||||||
example:
|
example:
|
||||||
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
|
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
|
||||||
Flags: []cli.Flag{
|
DisableSliceFlagSeparator: true,
|
||||||
&cli.StringSliceFlag{
|
Flags: append(defaultKeyFlags,
|
||||||
Name: "author",
|
append(reqFilterFlags,
|
||||||
Aliases: []string{"a"},
|
&cli.BoolFlag{
|
||||||
Usage: "only accept events from these authors (pubkey as hex)",
|
Name: "ids-only",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
Usage: "use nip77 to fetch just a list of ids",
|
||||||
},
|
},
|
||||||
&cli.StringSliceFlag{
|
&cli.BoolFlag{
|
||||||
Name: "id",
|
Name: "stream",
|
||||||
Aliases: []string{"i"},
|
Usage: "keep the subscription open, printing all events as they are returned",
|
||||||
Usage: "only accept events with these ids (hex)",
|
DefaultText: "false, will close on EOSE",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
},
|
||||||
},
|
&cli.BoolFlag{
|
||||||
&cli.IntSliceFlag{
|
Name: "paginate",
|
||||||
Name: "kind",
|
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
|
||||||
Aliases: []string{"k"},
|
DefaultText: "false",
|
||||||
Usage: "only accept events with these kind numbers",
|
},
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
&cli.DurationFlag{
|
||||||
},
|
Name: "paginate-interval",
|
||||||
&cli.StringSliceFlag{
|
Usage: "time between queries when using --paginate",
|
||||||
Name: "tag",
|
},
|
||||||
Aliases: []string{"t"},
|
&cli.UintFlag{
|
||||||
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
|
Name: "paginate-global-limit",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
Usage: "global limit at which --paginate should stop",
|
||||||
},
|
DefaultText: "uses the value given by --limit/-l or infinite",
|
||||||
&cli.StringSliceFlag{
|
},
|
||||||
Name: "e",
|
&cli.BoolFlag{
|
||||||
Usage: "shortcut for --tag e=<value>",
|
Name: "bare",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||||
},
|
},
|
||||||
&cli.StringSliceFlag{
|
&cli.BoolFlag{
|
||||||
Name: "p",
|
Name: "auth",
|
||||||
Usage: "shortcut for --tag p=<value>",
|
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
},
|
||||||
},
|
&cli.BoolFlag{
|
||||||
&cli.StringSliceFlag{
|
Name: "force-pre-auth",
|
||||||
Name: "d",
|
Aliases: []string{"fpa"},
|
||||||
Usage: "shortcut for --tag d=<value>",
|
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
Category: CATEGORY_SIGNER,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
)...,
|
||||||
Name: "since",
|
),
|
||||||
Aliases: []string{"s"},
|
|
||||||
Usage: "only accept events newer than this (unix timestamp)",
|
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "until",
|
|
||||||
Aliases: []string{"u"},
|
|
||||||
Usage: "only accept events older than this (unix timestamp)",
|
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
|
||||||
},
|
|
||||||
&cli.IntFlag{
|
|
||||||
Name: "limit",
|
|
||||||
Aliases: []string{"l"},
|
|
||||||
Usage: "only accept up to this number of events",
|
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "search",
|
|
||||||
Usage: "a NIP-50 search query, use it only with relays that explicitly support it",
|
|
||||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "stream",
|
|
||||||
Usage: "keep the subscription open, printing all events as they are returned",
|
|
||||||
DefaultText: "false, will close on EOSE",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "bare",
|
|
||||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "auth",
|
|
||||||
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "sec",
|
|
||||||
Usage: "secret key to sign the AUTH challenge, as hex or nsec",
|
|
||||||
DefaultText: "the key '1'",
|
|
||||||
Value: "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
},
|
|
||||||
&cli.BoolFlag{
|
|
||||||
Name: "prompt-sec",
|
|
||||||
Usage: "prompt the user to paste a hex or nsec with which to sign the AUTH challenge",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
var pool *nostr.SimplePool
|
|
||||||
relayUrls := c.Args().Slice()
|
relayUrls := c.Args().Slice()
|
||||||
if len(relayUrls) > 0 {
|
if len(relayUrls) > 0 {
|
||||||
var relays []*nostr.Relay
|
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
|
||||||
pool, relays = connectToAllRelays(c.Context, relayUrls, nostr.WithAuthHandler(func(evt *nostr.Event) error {
|
// connect to all relays we expect to use in this call in parallel
|
||||||
if !c.Bool("auth") {
|
forcePreAuthSigner := authSigner
|
||||||
return fmt.Errorf("auth not authorized")
|
if !c.Bool("force-pre-auth") {
|
||||||
}
|
forcePreAuthSigner = nil
|
||||||
sec, err := gatherSecretKeyFromArguments(c)
|
}
|
||||||
if err != nil {
|
relays := connectToAllRelays(
|
||||||
return err
|
ctx,
|
||||||
}
|
c,
|
||||||
pk, _ := nostr.GetPublicKey(sec)
|
relayUrls,
|
||||||
log("performing auth as %s...\n", pk)
|
forcePreAuthSigner,
|
||||||
return evt.Sign(sec)
|
nostr.WithAuthHandler(func(ctx context.Context, authEvent nostr.RelayEvent) error {
|
||||||
}))
|
return authSigner(ctx, c, func(s string, args ...any) {
|
||||||
|
if strings.HasPrefix(s, "authenticating as") {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(authEvent.Relay.URL, "wss://")
|
||||||
|
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
|
||||||
|
}
|
||||||
|
log(s+"\n", args...)
|
||||||
|
}, authEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// stop here already if all connections failed
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
@@ -148,90 +116,48 @@ example:
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
for stdinFilter := range getStdinLinesOrBlank() {
|
// go line by line from stdin or run once with input from flags
|
||||||
|
for stdinFilter := range getJsonsOrBlank() {
|
||||||
filter := nostr.Filter{}
|
filter := nostr.Filter{}
|
||||||
if stdinFilter != "" {
|
if stdinFilter != "" {
|
||||||
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
||||||
lineProcessingError(c, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
|
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authors := c.StringSlice("author"); len(authors) > 0 {
|
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||||
filter.Authors = append(filter.Authors, authors...)
|
return err
|
||||||
}
|
|
||||||
if ids := c.StringSlice("id"); len(ids) > 0 {
|
|
||||||
filter.IDs = append(filter.IDs, ids...)
|
|
||||||
}
|
|
||||||
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
|
|
||||||
filter.Kinds = append(filter.Kinds, kinds...)
|
|
||||||
}
|
|
||||||
if search := c.String("search"); search != "" {
|
|
||||||
filter.Search = search
|
|
||||||
}
|
|
||||||
tags := make([][]string, 0, 5)
|
|
||||||
for _, tagFlag := range c.StringSlice("tag") {
|
|
||||||
spl := strings.Split(tagFlag, "=")
|
|
||||||
if len(spl) == 2 && len(spl[0]) == 1 {
|
|
||||||
tags = append(tags, spl)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, etag := range c.StringSlice("e") {
|
|
||||||
tags = append(tags, []string{"e", etag})
|
|
||||||
}
|
|
||||||
for _, ptag := range c.StringSlice("p") {
|
|
||||||
tags = append(tags, []string{"p", ptag})
|
|
||||||
}
|
|
||||||
for _, dtag := range c.StringSlice("d") {
|
|
||||||
tags = append(tags, []string{"d", dtag})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tags) > 0 && filter.Tags == nil {
|
|
||||||
filter.Tags = make(nostr.TagMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tag := range tags {
|
|
||||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
|
||||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
|
||||||
}
|
|
||||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
if since := c.String("since"); since != "" {
|
|
||||||
if since == "now" {
|
|
||||||
ts := nostr.Now()
|
|
||||||
filter.Since = &ts
|
|
||||||
} else if i, err := strconv.Atoi(since); err == nil {
|
|
||||||
ts := nostr.Timestamp(i)
|
|
||||||
filter.Since = &ts
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("parse error: Invalid numeric literal %q", since)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if until := c.String("until"); until != "" {
|
|
||||||
if until == "now" {
|
|
||||||
ts := nostr.Now()
|
|
||||||
filter.Until = &ts
|
|
||||||
} else if i, err := strconv.Atoi(until); err == nil {
|
|
||||||
ts := nostr.Timestamp(i)
|
|
||||||
filter.Until = &ts
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("parse error: Invalid numeric literal %q", until)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if limit := c.Int("limit"); limit != 0 {
|
|
||||||
filter.Limit = limit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(relayUrls) > 0 {
|
if len(relayUrls) > 0 {
|
||||||
fn := pool.SubManyEose
|
if c.Bool("ids-only") {
|
||||||
if c.Bool("stream") {
|
seen := make(map[string]struct{}, max(500, filter.Limit))
|
||||||
fn = pool.SubMany
|
for _, url := range relayUrls {
|
||||||
}
|
ch, err := nip77.FetchIDsOnly(ctx, url, filter)
|
||||||
for ie := range fn(c.Context, relayUrls, nostr.Filters{filter}) {
|
if err != nil {
|
||||||
stdout(ie.Event)
|
log("negentropy call to %s failed: %s", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for id := range ch {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
stdout(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fn := sys.Pool.FetchMany
|
||||||
|
if c.Bool("paginate") {
|
||||||
|
fn = sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
|
||||||
|
} else if c.Bool("stream") {
|
||||||
|
fn = sys.Pool.SubscribeMany
|
||||||
|
}
|
||||||
|
|
||||||
|
for ie := range fn(ctx, relayUrls, filter) {
|
||||||
|
stdout(ie.Event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// no relays given, will just print the filter
|
// no relays given, will just print the filter
|
||||||
@@ -247,7 +173,134 @@ example:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reqFilterFlags = []cli.Flag{
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "author",
|
||||||
|
Aliases: []string{"a"},
|
||||||
|
Usage: "only accept events from these authors (pubkey as hex)",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "id",
|
||||||
|
Aliases: []string{"i"},
|
||||||
|
Usage: "only accept events with these ids (hex)",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.IntSliceFlag{
|
||||||
|
Name: "kind",
|
||||||
|
Aliases: []string{"k"},
|
||||||
|
Usage: "only accept events with these kind numbers",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "tag",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "e",
|
||||||
|
Usage: "shortcut for --tag e=<value>",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "p",
|
||||||
|
Usage: "shortcut for --tag p=<value>",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "d",
|
||||||
|
Usage: "shortcut for --tag d=<value>",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&NaturalTimeFlag{
|
||||||
|
Name: "since",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "only accept events newer than this (unix timestamp)",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&NaturalTimeFlag{
|
||||||
|
Name: "until",
|
||||||
|
Aliases: []string{"u"},
|
||||||
|
Usage: "only accept events older than this (unix timestamp)",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.UintFlag{
|
||||||
|
Name: "limit",
|
||||||
|
Aliases: []string{"l"},
|
||||||
|
Usage: "only accept up to this number of events",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "search",
|
||||||
|
Usage: "a nip50 search query, use it only with relays that explicitly support it",
|
||||||
|
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||||
|
if authors := c.StringSlice("author"); len(authors) > 0 {
|
||||||
|
filter.Authors = append(filter.Authors, authors...)
|
||||||
|
}
|
||||||
|
if ids := c.StringSlice("id"); len(ids) > 0 {
|
||||||
|
filter.IDs = append(filter.IDs, ids...)
|
||||||
|
}
|
||||||
|
for _, kind64 := range c.IntSlice("kind") {
|
||||||
|
filter.Kinds = append(filter.Kinds, int(kind64))
|
||||||
|
}
|
||||||
|
if search := c.String("search"); search != "" {
|
||||||
|
filter.Search = search
|
||||||
|
}
|
||||||
|
tags := make([][]string, 0, 5)
|
||||||
|
for _, tagFlag := range c.StringSlice("tag") {
|
||||||
|
spl := strings.SplitN(tagFlag, "=", 2)
|
||||||
|
if len(spl) == 2 {
|
||||||
|
tags = append(tags, spl)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, etag := range c.StringSlice("e") {
|
||||||
|
tags = append(tags, []string{"e", etag})
|
||||||
|
}
|
||||||
|
for _, ptag := range c.StringSlice("p") {
|
||||||
|
tags = append(tags, []string{"p", ptag})
|
||||||
|
}
|
||||||
|
for _, dtag := range c.StringSlice("d") {
|
||||||
|
tags = append(tags, []string{"d", dtag})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tags) > 0 && filter.Tags == nil {
|
||||||
|
filter.Tags = make(nostr.TagMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||||
|
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||||
|
}
|
||||||
|
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.IsSet("since") {
|
||||||
|
nts := getNaturalDate(c, "since")
|
||||||
|
filter.Since = &nts
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.IsSet("until") {
|
||||||
|
nts := getNaturalDate(c, "until")
|
||||||
|
filter.Until = &nts
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit := c.Uint("limit"); limit != 0 {
|
||||||
|
filter.Limit = int(limit)
|
||||||
|
} else if c.IsSet("limit") {
|
||||||
|
filter.LimitZero = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
128
serve.go
Normal file
128
serve.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bep/debounce"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/fiatjaf/eventstore/slicestore"
|
||||||
|
"github.com/fiatjaf/khatru"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serve = &cli.Command{
|
||||||
|
Name: "serve",
|
||||||
|
Usage: "starts an in-memory relay for testing purposes",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "hostname",
|
||||||
|
Usage: "hostname where to listen for connections",
|
||||||
|
Value: "localhost",
|
||||||
|
},
|
||||||
|
&cli.UintFlag{
|
||||||
|
Name: "port",
|
||||||
|
Usage: "port where to listen for connections",
|
||||||
|
Value: 10547,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "events",
|
||||||
|
Usage: "file containing the initial batch of events that will be served by the relay as newline-separated JSON (jsonl)",
|
||||||
|
DefaultText: "the relay will start empty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
db := slicestore.SliceStore{MaxLimit: math.MaxInt}
|
||||||
|
|
||||||
|
var scanner *bufio.Scanner
|
||||||
|
if path := c.String("events"); path != "" {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to file at '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
scanner = bufio.NewScanner(f)
|
||||||
|
} else if isPiped() {
|
||||||
|
scanner = bufio.NewScanner(os.Stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scanner != nil {
|
||||||
|
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
|
||||||
|
i := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
var evt nostr.Event
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
|
||||||
|
return fmt.Errorf("invalid event received at line %d: %s (`%s`)", i, err, scanner.Text())
|
||||||
|
}
|
||||||
|
db.SaveEvent(ctx, &evt)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rl := khatru.NewRelay()
|
||||||
|
|
||||||
|
rl.Info.Name = "nak serve"
|
||||||
|
rl.Info.Description = "a local relay for testing, debugging and development."
|
||||||
|
rl.Info.Software = "https://github.com/fiatjaf/nak"
|
||||||
|
rl.Info.Version = version
|
||||||
|
|
||||||
|
rl.QueryEvents = append(rl.QueryEvents, db.QueryEvents)
|
||||||
|
rl.CountEvents = append(rl.CountEvents, db.CountEvents)
|
||||||
|
rl.DeleteEvent = append(rl.DeleteEvent, db.DeleteEvent)
|
||||||
|
rl.StoreEvent = append(rl.StoreEvent, db.SaveEvent)
|
||||||
|
|
||||||
|
started := make(chan bool)
|
||||||
|
exited := make(chan error)
|
||||||
|
|
||||||
|
hostname := c.String("hostname")
|
||||||
|
port := int(c.Uint("port"))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := rl.Start(hostname, port, started)
|
||||||
|
exited <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var printStatus func()
|
||||||
|
|
||||||
|
// relay logging
|
||||||
|
rl.RejectFilter = append(rl.RejectFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||||
|
log(" got %s %v\n", color.HiYellowString("request"), colors.italic(filter))
|
||||||
|
printStatus()
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
rl.RejectCountFilter = append(rl.RejectCountFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||||
|
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
|
||||||
|
printStatus()
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
rl.RejectEvent = append(rl.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||||
|
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
|
||||||
|
printStatus()
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
|
||||||
|
d := debounce.New(time.Second * 2)
|
||||||
|
printStatus = func() {
|
||||||
|
d(func() {
|
||||||
|
totalEvents := 0
|
||||||
|
ch, _ := db.QueryEvents(ctx, nostr.Filter{})
|
||||||
|
for range ch {
|
||||||
|
totalEvents++
|
||||||
|
}
|
||||||
|
subs := rl.GetListeningFilters()
|
||||||
|
|
||||||
|
log(" %s events stored: %s, subscriptions opened: %s\n", color.HiMagentaString("•"), color.HiMagentaString("%d", totalEvents), color.HiMagentaString("%d", len(subs)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<-started
|
||||||
|
log("%s relay running at %s\n", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
|
||||||
|
|
||||||
|
return <-exited
|
||||||
|
},
|
||||||
|
}
|
||||||
17
verify.go
17
verify.go
@@ -1,10 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var verify = &cli.Command{
|
var verify = &cli.Command{
|
||||||
@@ -14,28 +14,29 @@ var verify = &cli.Command{
|
|||||||
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak verify
|
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak verify
|
||||||
|
|
||||||
it outputs nothing if the verification is successful.`,
|
it outputs nothing if the verification is successful.`,
|
||||||
Action: func(c *cli.Context) error {
|
DisableSliceFlagSeparator: true,
|
||||||
for stdinEvent := range getStdinLinesOrBlank() {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
for stdinEvent := range getStdinLinesOrArguments(c.Args()) {
|
||||||
evt := nostr.Event{}
|
evt := nostr.Event{}
|
||||||
if stdinEvent != "" {
|
if stdinEvent != "" {
|
||||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||||
lineProcessingError(c, "invalid event: %s", err)
|
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if evt.GetID() != evt.ID {
|
if evt.GetID() != evt.ID {
|
||||||
lineProcessingError(c, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
|
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, err := evt.CheckSignature(); !ok {
|
if ok, err := evt.CheckSignature(); !ok {
|
||||||
lineProcessingError(c, "invalid signature: %s", err)
|
ctx = lineProcessingError(ctx, "invalid signature: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(c)
|
exitIfLineProcessingError(ctx)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
478
wallet.go
Normal file
478
wallet.go
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip60"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip61"
|
||||||
|
"github.com/nbd-wtf/go-nostr/sdk"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(), error) {
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pk, err := kr.GetPublicKey(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
relays := sys.FetchOutboxRelays(ctx, pk, 3)
|
||||||
|
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays)
|
||||||
|
if w == nil {
|
||||||
|
return nil, nil, fmt.Errorf("error loading walle")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Processed = func(evt *nostr.Event, err error) {
|
||||||
|
if err == nil {
|
||||||
|
logverbose("processed event %s\n", evt)
|
||||||
|
} else {
|
||||||
|
log("error processing event %s: %s\n", evt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.PublishUpdate = func(event nostr.Event, deleted, received, change *nip60.Token, isHistory bool) {
|
||||||
|
desc := "wallet"
|
||||||
|
if received != nil {
|
||||||
|
mint, _ := strings.CutPrefix(received.Mint, "https://")
|
||||||
|
desc = fmt.Sprintf("received from %s with %d proofs totalling %d",
|
||||||
|
mint, len(received.Proofs), received.Proofs.Amount())
|
||||||
|
} else if change != nil {
|
||||||
|
mint, _ := strings.CutPrefix(change.Mint, "https://")
|
||||||
|
desc = fmt.Sprintf("change from %s with %d proofs totalling %d",
|
||||||
|
mint, len(change.Proofs), change.Proofs.Amount())
|
||||||
|
} else if deleted != nil {
|
||||||
|
mint, _ := strings.CutPrefix(deleted.Mint, "https://")
|
||||||
|
desc = fmt.Sprintf("deleting a used token from %s with %d proofs totalling %d",
|
||||||
|
mint, len(deleted.Proofs), deleted.Proofs.Amount())
|
||||||
|
} else if isHistory {
|
||||||
|
desc = "history entry"
|
||||||
|
}
|
||||||
|
|
||||||
|
log("- saving kind:%d event (%s)... ", event.Kind, desc)
|
||||||
|
first := true
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
log("%s: ok", colors.successf(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
<-w.Stable
|
||||||
|
|
||||||
|
return w, func() {
|
||||||
|
w.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var wallet = &cli.Command{
|
||||||
|
Name: "wallet",
|
||||||
|
Usage: "displays the current wallet balance",
|
||||||
|
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(w.Balance())
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "mints",
|
||||||
|
Usage: "lists, adds or remove default mints from the wallet",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range w.Mints {
|
||||||
|
stdout(strings.Split(url, "://")[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "add",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "<mint>...",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.AddMint(ctx, c.Args().Slice()...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "<mint>...",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.RemoveMint(ctx, c.Args().Slice()...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tokens",
|
||||||
|
Usage: "lists existing tokens with their mints and aggregated amounts",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range w.Tokens {
|
||||||
|
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "receive",
|
||||||
|
Usage: "takes a cashu token string as an argument and adds it to the wallet",
|
||||||
|
ArgsUsage: "<token>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "mint",
|
||||||
|
Usage: "mint to swap the token into",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("must be called as `nak wallet receive <token>")
|
||||||
|
}
|
||||||
|
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs, mint, err := nip60.GetProofsAndMint(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := make([]nip60.ReceiveOption, 0, 1)
|
||||||
|
for _, url := range c.StringSlice("mint") {
|
||||||
|
opts = append(opts, nip60.WithMintDestination(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Receive(ctx, proofs, mint, opts...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "send",
|
||||||
|
Usage: "prints a cashu token with the given amount for sending to someone else",
|
||||||
|
ArgsUsage: "<amount>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "mint",
|
||||||
|
Usage: "send from a specific mint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("must be called as `nak wallet send <amount>")
|
||||||
|
}
|
||||||
|
amount, err := strconv.ParseUint(args[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("amount '%s' is invalid", args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := make([]nip60.SendOption, 0, 1)
|
||||||
|
if mint := c.String("mint"); mint != "" {
|
||||||
|
mint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||||
|
opts = append(opts, nip60.WithMint(mint))
|
||||||
|
}
|
||||||
|
proofs, mint, err := w.Send(ctx, amount, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(nip60.MakeTokenString(proofs, mint))
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pay",
|
||||||
|
Usage: "pays a bolt11 lightning invoice and outputs the preimage",
|
||||||
|
ArgsUsage: "<invoice>",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "mint",
|
||||||
|
Usage: "pay from a specific mint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("must be called as `nak wallet pay <invoice>")
|
||||||
|
}
|
||||||
|
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := make([]nip60.SendOption, 0, 1)
|
||||||
|
if mint := c.String("mint"); mint != "" {
|
||||||
|
mint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||||
|
opts = append(opts, nip60.WithMint(mint))
|
||||||
|
}
|
||||||
|
|
||||||
|
preimage, err := w.PayBolt11(ctx, args[0], opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(preimage)
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "nutzap",
|
||||||
|
Usage: "sends a nip61 nutzap to one or more Nostr profiles and/or events",
|
||||||
|
ArgsUsage: "<amount> <target>",
|
||||||
|
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "mint",
|
||||||
|
Usage: "send from a specific mint",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "message",
|
||||||
|
Usage: "attach a message to the nutzap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
|
||||||
|
}
|
||||||
|
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := c.Uint("amount")
|
||||||
|
target := c.String("target")
|
||||||
|
|
||||||
|
var evt *nostr.Event
|
||||||
|
var eventId string
|
||||||
|
|
||||||
|
if strings.HasPrefix(target, "nevent1") {
|
||||||
|
evt, _, err = sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
eventId = evt.ID
|
||||||
|
target = evt.PubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
pm, err := sys.FetchProfileFromInput(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log("sending %d sat to '%s' (%s)", amount, pm.ShortName(), pm.Npub())
|
||||||
|
|
||||||
|
opts := make([]nip60.SendOption, 0, 1)
|
||||||
|
if mint := c.String("mint"); mint != "" {
|
||||||
|
mint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||||
|
opts = append(opts, nip60.WithMint(mint))
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, _ := gatherKeyerFromArguments(ctx, c)
|
||||||
|
results, err := nip61.SendNutzap(
|
||||||
|
ctx,
|
||||||
|
kr,
|
||||||
|
w,
|
||||||
|
sys.Pool,
|
||||||
|
pm.PubKey,
|
||||||
|
sys.FetchInboxRelays,
|
||||||
|
sys.FetchOutboxRelays(ctx, pm.PubKey, 3),
|
||||||
|
eventId,
|
||||||
|
amount,
|
||||||
|
c.String("message"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log("- publishing nutzap... ")
|
||||||
|
first := true
|
||||||
|
for res := range results {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
log("%s: ok", colors.successf(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "setup",
|
||||||
|
Usage: "setup your wallet private key and kind:10019 event for receiving nutzaps",
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "mint",
|
||||||
|
Usage: "mints to receive nutzaps in",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "private-key",
|
||||||
|
Usage: "private key used for receiving nutzaps",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "force",
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
Usage: "forces replacement of private-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
w, closew, err := prepareWallet(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.PrivateKey == nil {
|
||||||
|
if sk := c.String("private-key"); sk != "" {
|
||||||
|
if err := w.SetPrivateKey(ctx, sk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("missing --private-key")
|
||||||
|
}
|
||||||
|
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
|
||||||
|
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, _ := gatherKeyerFromArguments(ctx, c)
|
||||||
|
pk, _ := kr.GetPublicKey(ctx)
|
||||||
|
relays := sys.FetchWriteRelays(ctx, pk, 6)
|
||||||
|
|
||||||
|
info := nip61.Info{}
|
||||||
|
ie := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []int{10019},
|
||||||
|
Authors: []string{pk},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if ie != nil {
|
||||||
|
info.ParseEvent(ie.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
|
||||||
|
info.Mints = w.Mints
|
||||||
|
}
|
||||||
|
if len(info.Mints) == 0 {
|
||||||
|
return fmt.Errorf("missing --mint")
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{}
|
||||||
|
if err := info.ToEvent(ctx, kr, &evt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(evt)
|
||||||
|
log("- saving kind:10019 event... ")
|
||||||
|
first := true
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
log("%s: ok", colors.successf(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closew()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
14
zapstore.yaml
Normal file
14
zapstore.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
nak:
|
||||||
|
cli:
|
||||||
|
name: nak
|
||||||
|
summary: a command line tool for doing all things nostr
|
||||||
|
repository: https://github.com/fiatjaf/nak
|
||||||
|
artifacts:
|
||||||
|
nak-v%v-darwin-arm64:
|
||||||
|
platforms: [darwin-arm64]
|
||||||
|
nak-v%v-darwin-amd64:
|
||||||
|
platforms: [darwin-x86_64]
|
||||||
|
nak-v%v-linux-arm64:
|
||||||
|
platforms: [linux-aarch64]
|
||||||
|
nak-v%v-linux-amd64:
|
||||||
|
platforms: [linux-x86_64]
|
||||||
Reference in New Issue
Block a user