mirror of
https://github.com/fiatjaf/nak.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
463 Commits
v0.10.0
...
1403c7d2cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1403c7d2cd | ||
|
|
6855722ba5 | ||
|
|
aac995714b | ||
|
|
a9df9c6a21 | ||
|
|
57be7627d4 | ||
|
|
5b7f3eb4db | ||
|
|
6d4f4c03ec | ||
|
|
eb9afd0b54 | ||
|
|
9c35de2b02 | ||
|
|
b65683886b | ||
|
|
e36d546548 | ||
|
|
b6fa2e4f64 | ||
|
|
df2aaefb6d | ||
|
|
3e50a8cba7 | ||
|
|
c525a25e51 | ||
|
|
1cb730f34d | ||
|
|
1d6c352e92 | ||
|
|
d3347ec2b0 | ||
|
|
f288a59b3f | ||
|
|
549903dff2 | ||
|
|
2298e37cdf | ||
|
|
f10555037e | ||
|
|
e493fcdb03 | ||
|
|
03432424d2 | ||
|
|
ade6935c91 | ||
|
|
a73979c570 | ||
|
|
5874bbe588 | ||
|
|
59bcd97617 | ||
|
|
b420b81fd5 | ||
|
|
461e9c612e | ||
|
|
49713588bd | ||
|
|
80ce8059ea | ||
|
|
631c57b4bf | ||
|
|
0d3574c9fe | ||
|
|
e3aba47046 | ||
|
|
b46e9c7dd5 | ||
|
|
6bd793bea5 | ||
|
|
f96b9d458e | ||
|
|
d8e951e881 | ||
|
|
51f79e1d3c | ||
|
|
49f2839fb1 | ||
|
|
4911811e75 | ||
|
|
f1de27c88c | ||
|
|
514fed0b79 | ||
|
|
c1ebb829b9 | ||
|
|
d4d808e9b5 | ||
|
|
c9f7632571 | ||
|
|
9101a3a543 | ||
|
|
d419b35b66 | ||
|
|
fd8fdad26a | ||
|
|
232ef8d02e | ||
|
|
09539a317f | ||
|
|
bcc4f42cd7 | ||
|
|
23bc1d3aa3 | ||
|
|
469c99fb51 | ||
|
|
f2638ab2de | ||
|
|
dd0b25816a | ||
|
|
4fa512edf3 | ||
|
|
0055037943 | ||
|
|
ab32de2fb3 | ||
|
|
6a011ae7d9 | ||
|
|
7c28e75c4b | ||
|
|
4e0b7ecc08 | ||
|
|
586a8920e2 | ||
|
|
e1e8b11001 | ||
|
|
61b20c6e7a | ||
|
|
72e6e735f3 | ||
|
|
11abe3afca | ||
|
|
58cfd06eab | ||
|
|
c16a456811 | ||
|
|
5c754b1b4f | ||
|
|
0ad2fa3e65 | ||
|
|
3fa1e2267e | ||
|
|
97b544fb6e | ||
|
|
44db7af6c0 | ||
|
|
34281a9c8e | ||
|
|
4f798759dd | ||
|
|
198cbc6d4a | ||
|
|
5d45849595 | ||
|
|
8eb6b7303c | ||
|
|
002763669b | ||
|
|
ecb7514716 | ||
|
|
b188d7f803 | ||
|
|
d79fd1ba59 | ||
|
|
e62bf97acc | ||
|
|
c709647823 | ||
|
|
8563178bfd | ||
|
|
d374fe9bd3 | ||
|
|
4ea0edc52b | ||
|
|
6087c71072 | ||
|
|
0bcb90e37e | ||
|
|
98b7a1ae6a | ||
|
|
46a809d284 | ||
|
|
cbfc16f354 | ||
|
|
62fa610a06 | ||
|
|
5afdce2672 | ||
|
|
4fd7400272 | ||
|
|
40a934e3f9 | ||
|
|
12000232a7 | ||
|
|
cc879b6bf6 | ||
|
|
483bf94ff4 | ||
|
|
814059fcec | ||
|
|
b3e009730c | ||
|
|
7bbff0a3ad | ||
|
|
c97e1815b4 | ||
|
|
29113783b3 | ||
|
|
59c691f4d5 | ||
|
|
0c8e643609 | ||
|
|
7872c61a4d | ||
|
|
97950d1203 | ||
|
|
0960ceb6a6 | ||
|
|
6a95407939 | ||
|
|
ac988a792d | ||
|
|
fa41c22c12 | ||
|
|
c2902bfb5d | ||
|
|
0f7268c071 | ||
|
|
f7a00cec10 | ||
|
|
52cf03d423 | ||
|
|
d0c291b25c | ||
|
|
6ee60cb197 | ||
|
|
c2eb2d7bdd | ||
|
|
168a182d50 | ||
|
|
f202f038a4 | ||
|
|
1043b2a7af | ||
|
|
9c3b4b69b6 | ||
|
|
1c542deb5e | ||
|
|
cefc0b91d0 | ||
|
|
8c97c7becb | ||
|
|
ada938c3a8 | ||
|
|
c2d74b846e | ||
|
|
06bd1ddb11 | ||
|
|
ebd93964de | ||
|
|
a285ac6723 | ||
|
|
e2f791ab85 | ||
|
|
ed5af14305 | ||
|
|
e5f4b07c3d | ||
|
|
b41260f663 | ||
|
|
c9e1511865 | ||
|
|
5af1d111ba | ||
|
|
2922a0de25 | ||
|
|
35d628ef6d | ||
|
|
a2f109c8c8 | ||
|
|
d9cc9f1d80 | ||
|
|
d97937eaa4 | ||
|
|
bdd3aa1093 | ||
|
|
02c12cb671 | ||
|
|
c04add227d | ||
|
|
c3f1ef7877 | ||
|
|
d0e719fb93 | ||
|
|
187842214e | ||
|
|
405be6efa9 | ||
|
|
47e462fe55 | ||
|
|
99ef7a8c91 | ||
|
|
a152d7f633 | ||
|
|
25d2045640 | ||
|
|
09b9939956 | ||
|
|
2a8cd898c2 | ||
|
|
eae828e03b | ||
|
|
9ae5798a24 | ||
|
|
5930437578 | ||
|
|
2193491c3b | ||
|
|
d3f4548dbd | ||
|
|
07b791e90a | ||
|
|
2c3d72b337 | ||
|
|
46c68462db | ||
|
|
0834420d89 | ||
|
|
217352ed0b | ||
|
|
318838b3ff | ||
|
|
b811396d8b | ||
|
|
2bc1cf3417 | ||
|
|
65ddf7b821 | ||
|
|
e19306b124 | ||
|
|
e059fba487 | ||
|
|
00f44958de | ||
|
|
e37ea23a8d | ||
|
|
3c4cb533b4 | ||
|
|
be26dfca3c | ||
|
|
26518ec260 | ||
|
|
de599c64f6 | ||
|
|
3d650ee502 | ||
|
|
67920ccbdd | ||
|
|
0dbe14aa93 | ||
|
|
ec1721cfe3 | ||
|
|
3b318fec54 | ||
|
|
82275e4b9e | ||
|
|
ae98e99ec6 | ||
|
|
29db04b7ea | ||
|
|
59fecacbdc | ||
|
|
faac4d9440 | ||
|
|
f00a4a7d2a | ||
|
|
d823ac55a6 | ||
|
|
105ccef7ec | ||
|
|
9d4df21836 | ||
|
|
952d638ac3 | ||
|
|
c1e72e0af1 | ||
|
|
5f4efdbc69 | ||
|
|
4967db13a1 | ||
|
|
da0b753371 | ||
|
|
bef67d35d2 | ||
|
|
a8fb2e4189 | ||
|
|
7aea4cf9a1 | ||
|
|
d472efe707 | ||
|
|
17341b3af6 | ||
|
|
ff5a7b4ba7 | ||
|
|
7596e317b8 | ||
|
|
da7fc3fac3 | ||
|
|
0e8475b388 | ||
|
|
5532c884bc | ||
|
|
05857ab190 | ||
|
|
bf22a63404 | ||
|
|
6bd2d1cdfc | ||
|
|
9c8d59c5c5 | ||
|
|
5957c08d15 | ||
|
|
f59c8a670d | ||
|
|
61a3b89d08 | ||
|
|
d61fdc4cb4 | ||
|
|
0735ded0fc | ||
|
|
5233a77510 | ||
|
|
fd34cc7c5e | ||
|
|
2d151c2ac8 | ||
|
|
037e8efcc6 | ||
|
|
1b380dea9a | ||
|
|
f126b3f7ee | ||
|
|
dc5ffe5129 | ||
|
|
7637b5018f | ||
|
|
d5ab34bb2f | ||
|
|
49345333c4 | ||
|
|
b5de7b78bc | ||
|
|
ba9a5badc6 | ||
|
|
c0bbf73961 | ||
|
|
5320feee4f | ||
|
|
58c1fab0f0 | ||
|
|
5f30009e72 | ||
|
|
548918578b | ||
|
|
7757400ab3 | ||
|
|
3ee6320312 | ||
|
|
91474d65eb | ||
|
|
7d782737c4 | ||
|
|
9160c68cb5 | ||
|
|
bf19f38996 | ||
|
|
4e2c136e45 | ||
|
|
8cef1ed0ea | ||
|
|
e05b455a05 | ||
|
|
9190c9d988 | ||
|
|
e64ad8f078 | ||
|
|
b36718caaa | ||
|
|
5c658c38f1 | ||
|
|
2a5ce3b249 | ||
|
|
c0b85af734 | ||
|
|
cb2247c9da | ||
|
|
686d960f62 | ||
|
|
af04838153 | ||
|
|
c6da13649d | ||
|
|
acd6227dd0 | ||
|
|
00fbda9af7 | ||
|
|
e838de9b72 | ||
|
|
6dfbed4413 | ||
|
|
0e283368ed | ||
|
|
38775e0d93 | ||
|
|
fabcad3f61 | ||
|
|
69e4895e48 | ||
|
|
81524de04f | ||
|
|
8334474f96 | ||
|
|
87f27e214e | ||
|
|
32999917b4 | ||
|
|
a19a179548 | ||
|
|
9b684f2c65 | ||
|
|
6d87887855 | ||
|
|
e9c4deaf6d | ||
|
|
965a312b46 | ||
|
|
2e4079f92c | ||
|
|
5b64795015 | ||
|
|
5d4fe434c3 | ||
|
|
b95665d986 | ||
|
|
3be80c29df | ||
|
|
e01cfbde47 | ||
|
|
e91d4429ec | ||
|
|
21423b4a21 | ||
|
|
1b7f3162b5 | ||
|
|
8f38468103 | ||
|
|
9bf728d850 | ||
|
|
8396738fe2 | ||
|
|
c1d1682d6e | ||
|
|
6f00ff4c73 | ||
|
|
68bbece3db | ||
|
|
a83b23d76b | ||
|
|
a288cc47a4 | ||
|
|
5ee7670ba8 | ||
|
|
b973b476bc | ||
|
|
252612b12f | ||
|
|
4b8b6bb3de | ||
|
|
df491be232 | ||
|
|
1dab81f77c | ||
|
|
11228d7082 | ||
|
|
a422b5f708 | ||
|
|
852fe6bdfb | ||
|
|
210cf66d5f | ||
|
|
f9335b0ab4 | ||
|
|
16916d7d95 | ||
|
|
3ff4dbe196 | ||
|
|
2de3ff78ee | ||
|
|
03c1bf832e | ||
|
|
8df130a822 | ||
|
|
e04861fcee | ||
|
|
73d80203a0 | ||
|
|
c3cb59a94a | ||
|
|
59edaba5b8 | ||
|
|
11a690b1c6 | ||
|
|
9f8679591e | ||
|
|
75c1a88333 | ||
|
|
26fc7c338a | ||
|
|
ddc009a391 | ||
|
|
68e49fa6e5 | ||
|
|
79c1a70683 | ||
|
|
77afab780b | ||
|
|
a4f53021f0 | ||
|
|
afa31a58fc | ||
|
|
26f9b33d53 | ||
|
|
51876f89c4 | ||
|
|
ae3cb7c108 | ||
|
|
bec821d3c0 | ||
|
|
5d7240b112 | ||
|
|
bbe1661096 | ||
|
|
ea4ad84aa0 | ||
|
|
85a04aa7ce | ||
|
|
e0ca768695 | ||
|
|
bef3739a67 | ||
|
|
210c0aa282 | ||
|
|
2758285d51 | ||
|
|
ecb7f8f195 | ||
|
|
9251702460 | ||
|
|
13452e6916 | ||
|
|
cdd64e340f | ||
|
|
3b4d6046cf | ||
|
|
bf1690a041 | ||
|
|
88031c888b | ||
|
|
6f0e777324 | ||
|
|
b316646821 | ||
|
|
d3975679e4 | ||
|
|
23e27da077 | ||
|
|
1a221a133c | ||
|
|
a698c59b0b | ||
|
|
87bf5ef446 | ||
|
|
7c58948924 | ||
|
|
ff02e6890b | ||
|
|
fb377f4775 | ||
|
|
b1114766e5 | ||
|
|
e0febbf190 | ||
|
|
2d2e657778 | ||
|
|
d32654447a | ||
|
|
fea23aecc3 | ||
|
|
cc526acb10 | ||
|
|
fd19855543 | ||
|
|
ecfe3a298e | ||
|
|
9c5f68a955 | ||
|
|
0aef173e8b | ||
|
|
6e4a546212 | ||
|
|
55c9d4ee45 | ||
|
|
550c89d8d7 | ||
|
|
1e9be3ed84 | ||
|
|
79cbc57dde | ||
|
|
1e237b4c42 | ||
|
|
89ec8b9822 | ||
|
|
fba83ea39e | ||
|
|
bd5569955c | ||
|
|
35ea2582d8 | ||
|
|
fa63dbfea3 | ||
|
|
a6509909d0 | ||
|
|
239dd2d42a | ||
|
|
0073c9bdf1 | ||
|
|
b5bd2aecf6 | ||
|
|
f27ac6c0e3 | ||
|
|
6e5441aa18 | ||
|
|
61a68f3dca | ||
|
|
f450e735b6 | ||
|
|
1304a65179 | ||
|
|
aa89093d57 | ||
|
|
4387595437 | ||
|
|
4eb5e929d4 | ||
|
|
150625ee74 | ||
|
|
fc255b5a9a | ||
|
|
5bcf2da794 | ||
|
|
aadcc73906 | ||
|
|
f799c65779 | ||
|
|
c3822225b4 | ||
|
|
67e291e80d | ||
|
|
83195d9a00 | ||
|
|
f9033f778d | ||
|
|
9055f98f66 | ||
|
|
02f22a8c2f | ||
|
|
f98bd7483f | ||
|
|
3005c62566 | ||
|
|
e91a454fc0 | ||
|
|
148f6e8bcb | ||
|
|
024111a8be | ||
|
|
8fba611ad0 | ||
|
|
4d12550d74 | ||
|
|
5d44600f17 | ||
|
|
5a8c7df811 | ||
|
|
01be954ae6 | ||
|
|
d733a31898 | ||
|
|
1b43dbda02 | ||
|
|
e45b54ea62 | ||
|
|
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 |
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
||||
# git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# documentation
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
# development files
|
||||
justfile
|
||||
*.md
|
||||
|
||||
# test files
|
||||
*_test.go
|
||||
cli_test.go
|
||||
|
||||
# build artifacts
|
||||
nak
|
||||
*.exe
|
||||
mnt
|
||||
|
||||
# ide and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# os generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
98
.github/workflows/release-cli.yml
vendored
98
.github/workflows/release-cli.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, freebsd, darwin, windows]
|
||||
goarch: [arm, amd64, arm64, riscv64]
|
||||
goarch: [amd64, arm64, riscv64]
|
||||
exclude:
|
||||
- goarch: arm64
|
||||
goos: windows
|
||||
@@ -33,11 +33,7 @@ jobs:
|
||||
goos: windows
|
||||
- goarch: riscv64
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
- goarch: arm
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
- goarch: arm64
|
||||
goos: freebsd
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -51,3 +47,93 @@ jobs:
|
||||
md5sum: false
|
||||
sha256sum: false
|
||||
compress_assets: false
|
||||
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-all-for-all
|
||||
steps:
|
||||
- name: download and smoke test latest binary
|
||||
run: |
|
||||
set -eo pipefail # exit on error, and on pipe failures
|
||||
|
||||
echo "downloading nak binary from releases"
|
||||
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
|
||||
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
|
||||
chmod +x nak
|
||||
|
||||
echo "printing version..."
|
||||
./nak --version
|
||||
|
||||
# generate and manipulate keys
|
||||
echo "testing key operations..."
|
||||
SECRET_KEY=$(./nak key generate)
|
||||
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
|
||||
echo "generated key pair: $SECRET_KEY => $PUBLIC_KEY"
|
||||
|
||||
# create events
|
||||
echo "testing event creation..."
|
||||
./nak event -c "hello world"
|
||||
HELLOWORLD=$(./nak event -c "hello world")
|
||||
echo " hello world again: $HELLOWORLD"
|
||||
./nak event --ts "2 days ago" -c "event with timestamp"
|
||||
./nak event -k 1 -t "t=test" -c "event with tag"
|
||||
|
||||
# test NIP-19 encoding/decoding
|
||||
echo "testing NIP-19 encoding/decoding..."
|
||||
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
|
||||
echo "encoded nsec: $NSEC"
|
||||
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||
EVENT_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
|
||||
NEVENT1=$(./nak encode nevent $EVENT_ID)
|
||||
echo "encoded nevent1: $NEVENT1"
|
||||
./nak decode $NEVENT1
|
||||
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
|
||||
|
||||
# test event verification
|
||||
echo "testing event verification..."
|
||||
# create an event and verify it
|
||||
VERIFY_EVENT=$(./nak event -c "verify me")
|
||||
echo $VERIFY_EVENT | ./nak verify
|
||||
|
||||
# test PoW
|
||||
echo "testing pow..."
|
||||
./nak event -c "testing pow" --pow 8
|
||||
|
||||
# test NIP-49 key encryption/decryption
|
||||
echo "testing NIP-49 key encryption/decryption..."
|
||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||
echo "nip-49 encryption/decryption test failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# test multi-value tags
|
||||
echo "testing multi-value tags..."
|
||||
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "testing multi-value tags"
|
||||
|
||||
# test relay operations (with a public relay)
|
||||
echo "testing publishing..."
|
||||
# publish a simple event to a public relay
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
|
||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||
echo "published event ID: $EVENT_ID"
|
||||
|
||||
# wait a moment for propagation
|
||||
sleep 2
|
||||
|
||||
# fetch the event we just published
|
||||
./nak req -i $EVENT_ID nos.lol
|
||||
|
||||
# test serving (just start and immediately kill)
|
||||
echo "testing serve command..."
|
||||
timeout 2s ./nak serve || true
|
||||
|
||||
# test filesystem mount (just start and immediately kill)
|
||||
echo "testing fs mount command..."
|
||||
mkdir -p /tmp/nostr-mount
|
||||
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
|
||||
|
||||
echo "all tests passed"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
nak
|
||||
mnt
|
||||
nak.exe
|
||||
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# install git and ca-certificates (needed for fetching dependencies)
|
||||
RUN apk add --no-cache git ca-certificates gcc musl-dev
|
||||
|
||||
# set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# copy go mod files first for better caching
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# copy source code
|
||||
COPY . .
|
||||
|
||||
# build the application
|
||||
# use cgo_enabled=0 to create a static binary
|
||||
# use -ldflags to strip debug info and reduce binary size
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
|
||||
|
||||
# runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# install ca-certificates for https requests (needed for relay connections)
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# create a non-root user
|
||||
RUN adduser -D -s /bin/sh nakuser
|
||||
|
||||
# set working directory
|
||||
WORKDIR /home/nakuser
|
||||
|
||||
# copy the binary from builder stage
|
||||
COPY --from=builder /app/nak /usr/local/bin/nak
|
||||
|
||||
# make sure the binary is executable
|
||||
RUN chmod +x /usr/local/bin/nak
|
||||
|
||||
# switch to non-root user
|
||||
USER nakuser
|
||||
|
||||
# set the entrypoint
|
||||
ENTRYPOINT ["nak"]
|
||||
|
||||
# default command (show help)
|
||||
CMD ["--help"]
|
||||
208
admin.go
Normal file
208
admin.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip86"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var admin = &cli.Command{
|
||||
Name: "admin",
|
||||
Usage: "manage relays using the relay management API",
|
||||
Description: `examples:
|
||||
nak admin allowpubkey myrelay.com --pubkey 1234... --reason "good user"
|
||||
nak admin banpubkey myrelay.com --pubkey 1234... --reason "spam"
|
||||
nak admin listallowedpubkeys myrelay.com
|
||||
nak admin changerelayname myrelay.com --name "My Relay"`,
|
||||
ArgsUsage: "<relay-url>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: (func() []*cli.Command {
|
||||
methods := []struct {
|
||||
method string
|
||||
args []string
|
||||
}{
|
||||
{"allowpubkey", []string{"pubkey", "reason"}},
|
||||
{"banpubkey", []string{"pubkey", "reason"}},
|
||||
{"listallowedpubkeys", nil},
|
||||
{"listbannedpubkeys", nil},
|
||||
{"listeventsneedingmoderation", nil},
|
||||
{"allowevent", []string{"id", "reason"}},
|
||||
{"banevent", []string{"id", "reason"}},
|
||||
{"listbannedevents", nil},
|
||||
{"changerelayname", []string{"name"}},
|
||||
{"changerelaydescription", []string{"description"}},
|
||||
{"changerelayicon", []string{"icon"}},
|
||||
{"allowkind", []string{"kind"}},
|
||||
{"disallowkind", []string{"kind"}},
|
||||
{"listallowedkinds", nil},
|
||||
{"blockip", []string{"ip", "reason"}},
|
||||
{"unblockip", []string{"ip", "reason"}},
|
||||
{"listblockedips", nil},
|
||||
{"listallowedevents", nil},
|
||||
{"listdisallowedkinds", nil},
|
||||
{"supportedmethods", nil},
|
||||
{"stats", nil},
|
||||
{"grantadmin", []string{"pubkey", "methods"}},
|
||||
{"revokeadmin", []string{"pubkey", "methods"}},
|
||||
{"createrole", []string{"role_id", "label", "description", "color", "order"}},
|
||||
{"editrole", []string{"role_id", "label", "description", "color", "order"}},
|
||||
{"deleterole", []string{"role_id"}},
|
||||
{"assignrole", []string{"pubkey", "role_id"}},
|
||||
{"unassignrole", []string{"pubkey", "role_id"}},
|
||||
}
|
||||
|
||||
commands := make([]*cli.Command, 0, len(methods))
|
||||
for _, def := range methods {
|
||||
def := def
|
||||
|
||||
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
|
||||
for i, argName := range def.args {
|
||||
flags[i] = declareFlag(argName)
|
||||
}
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: def.method,
|
||||
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
|
||||
Description: fmt.Sprintf(
|
||||
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
|
||||
Flags: flags,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
params := make([]any, len(def.args))
|
||||
for i, argName := range def.args {
|
||||
params[i] = getArgument(c, argName)
|
||||
}
|
||||
req := nip86.Request{Method: def.method, Params: params}
|
||||
reqj, _ := json.Marshal(req)
|
||||
|
||||
relayUrls := c.Args().Slice()
|
||||
if len(relayUrls) == 0 {
|
||||
stdout(string(reqj))
|
||||
return nil
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, relayUrl := range relayUrls {
|
||||
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
|
||||
log("calling '%s' on %s... ", def.method, httpUrl)
|
||||
body := bytes.NewBuffer(nil)
|
||||
body.Write(reqj)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Authorization
|
||||
payloadHash := sha256.Sum256(reqj)
|
||||
tokenEvent := nostr.Event{
|
||||
Kind: 27235,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"u", httpUrl},
|
||||
{"method", "POST"},
|
||||
{"payload", hex.EncodeToString(payloadHash[:])},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
|
||||
return fmt.Errorf("failed to sign token event: %w", err)
|
||||
}
|
||||
evtj, _ := json.Marshal(tokenEvent)
|
||||
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
|
||||
|
||||
// Content-Type
|
||||
req.Header.Set("Content-Type", "application/nostr+json+rpc")
|
||||
|
||||
// make request to relay
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log("failed: %s\n", err)
|
||||
continue
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
log("failed to read response: %s\n", err)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
log("failed with status %d\n", resp.StatusCode)
|
||||
bodyPrintable := string(b)
|
||||
if len(bodyPrintable) > 300 {
|
||||
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||
}
|
||||
log("%s", bodyPrintable)
|
||||
continue
|
||||
}
|
||||
var response nip86.Response
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
log("bad json response: %s\n", err)
|
||||
bodyPrintable := string(b)
|
||||
if len(bodyPrintable) > 300 {
|
||||
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||
}
|
||||
log("%s", bodyPrintable)
|
||||
continue
|
||||
}
|
||||
|
||||
// print the result
|
||||
log("\n")
|
||||
pretty, _ := json.MarshalIndent(response, "", " ")
|
||||
stdout(string(pretty))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands
|
||||
})(),
|
||||
}
|
||||
|
||||
func declareFlag(argName string) cli.Flag {
|
||||
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
|
||||
switch argName {
|
||||
case "pubkey":
|
||||
return &PubKeyFlag{Name: argName, Usage: usage}
|
||||
case "id":
|
||||
return &IDFlag{Name: argName, Usage: usage}
|
||||
case "kind", "order", "color":
|
||||
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
|
||||
case "reason":
|
||||
return &cli.StringFlag{Name: argName, Usage: usage}
|
||||
case "methods":
|
||||
return &cli.StringSliceFlag{Name: argName, Required: true, Usage: usage}
|
||||
default:
|
||||
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
|
||||
}
|
||||
}
|
||||
|
||||
func getArgument(c *cli.Command, argName string) any {
|
||||
switch argName {
|
||||
case "pubkey":
|
||||
return getPubKey(c, argName)
|
||||
case "id":
|
||||
return getID(c, argName)
|
||||
case "kind", "order", "color":
|
||||
return c.Int(argName)
|
||||
case "methods":
|
||||
return c.StringSlice(argName)
|
||||
default:
|
||||
return c.String(argName)
|
||||
}
|
||||
}
|
||||
328
blossom.go
Normal file
328
blossom.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var blossomCmd = &cli.Command{
|
||||
Name: "blossom",
|
||||
Suggest: true,
|
||||
UseShortOptionHandling: true,
|
||||
Usage: "an army knife for blossom things",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "the hostname of the target mediaserver",
|
||||
Required: true,
|
||||
},
|
||||
),
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "lists blobs from a pubkey",
|
||||
Description: `takes one pubkey passed as an argument or derives one from the --sec supplied. if that is given then it will also pre-authorize the list, which some servers may require.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[pubkey]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
var client *blossom.Client
|
||||
pubkey := c.Args().First()
|
||||
if pubkey != "" {
|
||||
pk, err := parsePubKey(pubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
|
||||
}
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
|
||||
} else {
|
||||
var err error
|
||||
client, err = getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
bds, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bd := range bds {
|
||||
stdout(bd)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "upload",
|
||||
Usage: "uploads a file to a specific mediaserver.",
|
||||
Description: `takes any number of local file paths and uploads them to a mediaserver, printing the resulting blob descriptions when successful.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[files...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
servers := c.StringSlice("server")
|
||||
|
||||
if len(servers) == 0 {
|
||||
return fmt.Errorf("no server specified")
|
||||
}
|
||||
|
||||
if isPiped() {
|
||||
// get file from stdin
|
||||
if c.Args().Len() > 0 {
|
||||
return fmt.Errorf("do not pass arguments when piping from stdin")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read stdin: %w", err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
} else {
|
||||
// get filenames from arguments
|
||||
for _, fpath := range c.Args().Slice() {
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadFilePath(ctx, fpath)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Usage: "downloads files from mediaservers",
|
||||
Description: `takes any number of sha256 hashes as hex, downloads them and prints them to stdout (unless --output is specified).`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[sha256...]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "file name to save downloaded file to, can be passed multiple times when downloading multiple hashes",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputs := c.StringSlice("output")
|
||||
|
||||
hasError := false
|
||||
var hash [32]byte
|
||||
for i, hhash := range c.Args().Slice() {
|
||||
if len(hhash) != 64 {
|
||||
log("invalid blob hash '%s'\n", hhash)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
|
||||
log("invalid blob hash '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
if len(outputs)-1 >= i && outputs[i] != "--" {
|
||||
// save to this file
|
||||
err := client.DownloadToFile(ctx, hash, outputs[i])
|
||||
if err != nil {
|
||||
log("download failed for '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// if output wasn't specified, print to stdout
|
||||
data, err := client.Download(ctx, hash)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(2)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "del",
|
||||
Aliases: []string{"delete"},
|
||||
Usage: "deletes a file from a mediaserver",
|
||||
Description: `takes any number of sha256 hashes, signs authorizations and deletes them from the current mediaserver.
|
||||
|
||||
if any of the files are not deleted command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it successfully deletes to stdout.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[sha256...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasError := false
|
||||
for _, hash := range c.Args().Slice() {
|
||||
err := client.Delete(ctx, hash)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
stdout(hash)
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(3)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check",
|
||||
Usage: "asks the mediaserver if it has the specified hashes.",
|
||||
Description: `uses the HEAD request to succintly check if the server has the specified sha256 hash.
|
||||
|
||||
if any of the files are not found the command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it finds to stdout.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[sha256...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasError := false
|
||||
for _, hash := range c.Args().Slice() {
|
||||
// a sha256 is 64 hex characters, anything after that is an extension
|
||||
if len(hash) > 64 {
|
||||
hash = hash[0:64]
|
||||
}
|
||||
|
||||
err := client.Check(ctx, hash)
|
||||
if err != nil {
|
||||
hasError = true
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
stdout(hash)
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(2)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mirror",
|
||||
Usage: "mirrors a from a server to another",
|
||||
Description: `examples:
|
||||
mirroring a single blob:
|
||||
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
|
||||
|
||||
mirroring all blobs from a certain pubkey from one server to the other:
|
||||
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bd blossom.BlobDescriptor
|
||||
if input := c.Args().First(); input != "" {
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
for input := range getJsonsOrBlank() {
|
||||
if input == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
|
||||
continue
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(string(out))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, error) {
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blossom.NewClient(servers[0], keyer), nil
|
||||
}
|
||||
682
bunker.go
682
bunker.go
@@ -1,109 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip46"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"github.com/nbd-wtf/go-nostr/nip46"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const PERSISTENCE = "PERSISTENCE"
|
||||
|
||||
var bunker = &cli.Command{
|
||||
Name: "bunker",
|
||||
Usage: "starts a NIP-46 signer daemon with the given --sec key",
|
||||
Usage: "starts a nip46 signer daemon with the given --sec key",
|
||||
ArgsUsage: "[relay...]",
|
||||
Description: ``,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as hex or nsec",
|
||||
DefaultText: "the key '1'",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "prompt-sec",
|
||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||
Name: "persist",
|
||||
Usage: "whether to read and store authorized keys from and to a config file",
|
||||
Category: PERSISTENCE,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "profile",
|
||||
Value: "default",
|
||||
Usage: "config file name to use for --persist mode (implies that if provided) -- based on --config-path, i.e. ~/.config/nak/",
|
||||
OnlyOnce: true,
|
||||
Category: PERSISTENCE,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "authorized-secrets",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "secrets for which we will always respond",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
&PubKeySliceFlag{
|
||||
Name: "authorized-keys",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "pubkeys for which we will always respond",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Usage: "relays to connect to (can also be provided as naked arguments)",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "qrcode",
|
||||
Usage: "display a QR code for the bunker URI",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// try to connect to the relays here
|
||||
qs := url.Values{}
|
||||
relayURLs := make([]string, 0, c.Args().Len())
|
||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||
relays := connectToAllRelays(ctx, relayUrls, false)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
// read config from file
|
||||
config := BunkerConfig{}
|
||||
baseRelaysUrls := nostr.AppendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
for i, url := range baseRelaysUrls {
|
||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
baseAuthorizedKeys := getPubKeySlice(c, "authorized-keys")
|
||||
|
||||
var baseSecret plainOrEncryptedKey
|
||||
{
|
||||
sec := c.String("sec")
|
||||
if c.Bool("prompt-sec") {
|
||||
var err error
|
||||
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret key: %w", err)
|
||||
}
|
||||
}
|
||||
for _, relay := range relays {
|
||||
relayURLs = append(relayURLs, relay.URL)
|
||||
qs.Add("relay", relay.URL)
|
||||
if strings.HasPrefix(sec, "ncryptsec1") {
|
||||
baseSecret.Encrypted = &sec
|
||||
} else if sec != "" {
|
||||
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
|
||||
sk := ski.(nostr.SecretKey)
|
||||
baseSecret.Plain = &sk
|
||||
} else if sk, err := nostr.SecretKeyFromHex(sec); err != nil {
|
||||
return fmt.Errorf("invalid secret key: %w", err)
|
||||
} else {
|
||||
baseSecret.Plain = &sk
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(relayURLs) == 0 {
|
||||
|
||||
// default case: persist() is nil
|
||||
var persist func()
|
||||
|
||||
if c.Bool("persist") || c.IsSet("profile") {
|
||||
path := filepath.Join(c.String("config-path"), "bunker")
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.Join(path, c.String("profile"))
|
||||
|
||||
persist = func() {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
|
||||
log(color.YellowString("reading config from %s\n"), path)
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(b, &config); err != nil {
|
||||
return err
|
||||
}
|
||||
// convert from deprecated field
|
||||
if len(config.AuthorizedKeys) > 0 {
|
||||
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
|
||||
for i := range config.AuthorizedKeys {
|
||||
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
|
||||
}
|
||||
config.AuthorizedKeys = nil
|
||||
persist()
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, url := range config.Relays {
|
||||
config.Relays[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
config.Relays = nostr.AppendUnique(config.Relays, baseRelaysUrls...)
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||
// we don't have any secret key stored, so just use whatever was given via flags
|
||||
config.Secret = baseSecret
|
||||
} else if baseSecret.Plain == nil && baseSecret.Encrypted == nil {
|
||||
// we didn't provide any keys, so we just use the stored
|
||||
} else {
|
||||
// we have a secret key stored
|
||||
// if we also provided a key we check if they match and fail otherwise
|
||||
if !baseSecret.equals(config.Secret) {
|
||||
return fmt.Errorf("--sec provided conflicts with stored, you should create a new --profile or omit the --sec flag")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.Secret = baseSecret
|
||||
config.Relays = baseRelaysUrls
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||
sec := os.Getenv("NOSTR_SECRET_KEY")
|
||||
if sec == "" {
|
||||
sec = defaultKey().Hex()
|
||||
}
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("default key is wrong: %w", err)
|
||||
}
|
||||
config.Secret.Plain = &sk
|
||||
}
|
||||
|
||||
if len(config.Relays) == 0 {
|
||||
return fmt.Errorf("no relays given")
|
||||
}
|
||||
|
||||
// decrypt key here if necessary
|
||||
var sec nostr.SecretKey
|
||||
if config.Secret.Plain != nil {
|
||||
sec = *config.Secret.Plain
|
||||
} else {
|
||||
plain, err := promptDecrypt(*config.Secret.Encrypted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
sec = plain
|
||||
}
|
||||
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
// try to connect to the relays here
|
||||
qs := url.Values{}
|
||||
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||
copy(allRelays, config.Relays)
|
||||
for _, c := range config.Clients {
|
||||
for _, url := range c.CustomRelays {
|
||||
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
|
||||
allRelays = append(allRelays, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
relays := connectToAllRelays(ctx, c, allRelays)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
for _, relay := range config.Relays {
|
||||
qs.Add("relay", relay)
|
||||
}
|
||||
if len(relays) == 0 {
|
||||
return fmt.Errorf("not connected to any relays: please specify at least one")
|
||||
}
|
||||
|
||||
// gather the secret key
|
||||
sec, _, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// other arguments
|
||||
authorizedKeys := c.StringSlice("authorized-keys")
|
||||
authorizedSecrets := c.StringSlice("authorized-secrets")
|
||||
|
||||
// this will be used to auto-authorize the next person who connects who isn't pre-authorized
|
||||
// it will be stored
|
||||
newSecret := randString(12)
|
||||
|
||||
// guards config.Clients, newSecret and cancelPreviousBunkerInfoPrint, which are
|
||||
// accessed from the socket goroutine, the per-request handler goroutines and here
|
||||
var mu sync.Mutex
|
||||
|
||||
// static information
|
||||
pubkey, err := nostr.GetPublicKey(sec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
npub, _ := nip19.EncodePublicKey(pubkey)
|
||||
bold := color.New(color.Bold).Sprint
|
||||
italic := color.New(color.Italic).Sprint
|
||||
pubkey := sec.Public()
|
||||
npub := nip19.EncodeNpub(pubkey)
|
||||
|
||||
// this function will be called every now and then
|
||||
printBunkerInfo := func() {
|
||||
qs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode())
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
iqs := make(url.Values)
|
||||
maps.Copy(iqs, qs)
|
||||
iqs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), iqs.Encode())
|
||||
|
||||
authorizedKeysStr := ""
|
||||
if len(authorizedKeys) != 0 {
|
||||
authorizedKeysStr = "\n authorized keys:\n - " + italic(strings.Join(authorizedKeys, "\n - "))
|
||||
if len(config.Clients) != 0 {
|
||||
authorizedKeysStr = "\n authorized clients:"
|
||||
for _, c := range config.Clients {
|
||||
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
|
||||
name := ""
|
||||
if c.Name != "" {
|
||||
name = c.Name
|
||||
if c.URL != "" {
|
||||
name += " " + colors.underline(c.URL)
|
||||
}
|
||||
} else if c.URL != "" {
|
||||
name = colors.underline(c.URL)
|
||||
}
|
||||
if name != "" {
|
||||
authorizedKeysStr += " (" + name + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authorizedSecretsStr := ""
|
||||
if len(authorizedSecrets) != 0 {
|
||||
authorizedSecretsStr = "\n authorized secrets:\n - " + italic(strings.Join(authorizedSecrets, "\n - "))
|
||||
authorizedSecretsStr = "\n authorized secrets:\n - " + colors.italic(strings.Join(authorizedSecrets, "\n - "))
|
||||
}
|
||||
|
||||
preauthorizedFlags := ""
|
||||
for _, k := range authorizedKeys {
|
||||
preauthorizedFlags += " -k " + k
|
||||
for _, c := range config.Clients {
|
||||
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||
}
|
||||
for _, s := range authorizedSecrets {
|
||||
preauthorizedFlags += " -s " + s
|
||||
@@ -114,8 +292,8 @@ var bunker = &cli.Command{
|
||||
secretKeyFlag = "--sec " + sec
|
||||
}
|
||||
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
|
||||
for i, url := range relayURLs {
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(config.Relays))
|
||||
for i, url := range config.Relays {
|
||||
if strings.HasPrefix(url, "wss://") {
|
||||
relayURLsPossiblyWithoutSchema[i] = url[6:]
|
||||
} else {
|
||||
@@ -123,49 +301,81 @@ var bunker = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
|
||||
secretKeyFlag,
|
||||
preauthorizedFlags,
|
||||
strings.Join(relayURLsPossiblyWithoutSchema, " "),
|
||||
)
|
||||
// only print the restart command if not persisting:
|
||||
if persist == nil {
|
||||
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
|
||||
secretKeyFlag,
|
||||
preauthorizedFlags,
|
||||
strings.Join(relayURLsPossiblyWithoutSchema, " "),
|
||||
)
|
||||
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
||||
bold(relayURLs),
|
||||
bold(pubkey),
|
||||
bold(npub),
|
||||
authorizedKeysStr,
|
||||
authorizedSecretsStr,
|
||||
color.CyanString(restartCommand),
|
||||
bold(bunkerURI),
|
||||
)
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
authorizedSecretsStr,
|
||||
color.CyanString(restartCommand),
|
||||
colors.bold(bunkerURI),
|
||||
)
|
||||
} else {
|
||||
// otherwise just print the data
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
authorizedSecretsStr,
|
||||
colors.bold(bunkerURI),
|
||||
)
|
||||
}
|
||||
|
||||
// print QR code if requested
|
||||
if c.Bool("qrcode") {
|
||||
log("QR Code for bunker URI:\n")
|
||||
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
|
||||
log("\n\n")
|
||||
}
|
||||
}
|
||||
printBunkerInfo()
|
||||
|
||||
// subscribe to relays
|
||||
now := nostr.Now()
|
||||
events := sys.Pool.SubMany(ctx, relayURLs, nostr.Filters{
|
||||
{
|
||||
Kinds: []int{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey}},
|
||||
Since: &now,
|
||||
LimitZero: true,
|
||||
},
|
||||
})
|
||||
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||
|
||||
signer := nip46.NewStaticKeySigner(sec)
|
||||
handlerWg := sync.WaitGroup{}
|
||||
printLock := sync.Mutex{}
|
||||
signer.DefaultRelays = config.Relays
|
||||
|
||||
// just a gimmick
|
||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||
_, cancel := context.WithCancel(ctx)
|
||||
cancelPreviousBunkerInfoPrint = cancel
|
||||
|
||||
// asking user for authorization
|
||||
signer.AuthorizeRequest = func(harmless bool, from string, secret string) bool {
|
||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(authorizedSecrets, secret) {
|
||||
// add client to authorized list for subsequent requests
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if secret == newSecret {
|
||||
// store this key
|
||||
authorizedKeys = append(authorizedKeys, from)
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
// discard this and generate a new secret
|
||||
newSecret = randString(12)
|
||||
// print bunker info again after this
|
||||
@@ -173,49 +383,66 @@ var bunker = &cli.Command{
|
||||
time.Sleep(3 * time.Second)
|
||||
printBunkerInfo()
|
||||
}()
|
||||
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
||||
return false
|
||||
}
|
||||
|
||||
for ie := range events {
|
||||
handleBunkerRequest := func(ie nostr.RelayEvent) {
|
||||
mu.Lock()
|
||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||
mu.Unlock()
|
||||
|
||||
// handle the NIP-46 request event
|
||||
from := ie.Event.PubKey
|
||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||
if err != nil {
|
||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
||||
continue
|
||||
if errors.Is(err, nip46.AlreadyHandled) {
|
||||
return
|
||||
}
|
||||
|
||||
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey), string(jreq))
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
|
||||
handlerWg.Add(len(relayURLs))
|
||||
for _, relayURL := range relayURLs {
|
||||
go func(relayURL string) {
|
||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
||||
err := relay.Publish(ctx, eventResponse)
|
||||
printLock.Lock()
|
||||
if err == nil {
|
||||
log("* sent response through %s\n", relay.URL)
|
||||
} else {
|
||||
log("* failed to send response: %s\n", err)
|
||||
}
|
||||
printLock.Unlock()
|
||||
handlerWg.Done()
|
||||
}
|
||||
}(relayURL)
|
||||
// use custom relays if they are defined for this client
|
||||
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||
relays := config.Relays
|
||||
mu.Lock()
|
||||
for _, c := range config.Clients {
|
||||
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||
relays = c.CustomRelays
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent response through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
handlerWg.Wait()
|
||||
|
||||
// just after handling one request we trigger this
|
||||
go func() {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
mu.Lock()
|
||||
cancelPreviousBunkerInfoPrint = cancel
|
||||
mu.Unlock()
|
||||
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
|
||||
// but we will only do if the bunker is inactive for more than 5 minutes
|
||||
select {
|
||||
@@ -227,6 +454,267 @@ var bunker = &cli.Command{
|
||||
}()
|
||||
}
|
||||
|
||||
// unix socket nostrconnect:// handling
|
||||
go func() {
|
||||
for uri := range onSocketConnect(ctx, c) {
|
||||
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
|
||||
|
||||
relays := uri.Query()["relay"]
|
||||
|
||||
// pre-authorize this client since the user has explicitly added it
|
||||
mu.Lock()
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
||||
return c.PubKey == clientPublicKey
|
||||
}) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{
|
||||
PubKey: clientPublicKey,
|
||||
Name: uri.Query().Get("name"),
|
||||
URL: uri.Query().Get("url"),
|
||||
Icon: uri.Query().Get("icon"),
|
||||
CustomRelays: relays,
|
||||
})
|
||||
}
|
||||
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
||||
if err != nil {
|
||||
log("* failed to handle: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
|
||||
// handle directly instead of forwarding into the main events
|
||||
// channel, which is owned (and eventually closed) by the pool
|
||||
go handleBunkerRequest(event)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(time.Millisecond * 25)
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for ie := range events {
|
||||
go handleBunkerRequest(ie)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "connect",
|
||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||
ArgsUsage: "<nostrconnect-uri>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "profile",
|
||||
Usage: "profile name of the bunker to connect to",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||
}
|
||||
|
||||
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type BunkerConfig struct {
|
||||
Clients []BunkerConfigClient `json:"clients"`
|
||||
Secret plainOrEncryptedKey `json:"sec"`
|
||||
Relays []string `json:"relays"`
|
||||
|
||||
// deprecated
|
||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
|
||||
}
|
||||
|
||||
type BunkerConfigClient struct {
|
||||
PubKey nostr.PubKey `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
CustomRelays []string `json:"custom_relays,omitempty"`
|
||||
}
|
||||
|
||||
type plainOrEncryptedKey struct {
|
||||
Plain *nostr.SecretKey
|
||||
Encrypted *string
|
||||
}
|
||||
|
||||
func (pe plainOrEncryptedKey) MarshalJSON() ([]byte, error) {
|
||||
if pe.Plain != nil {
|
||||
res := make([]byte, 66)
|
||||
hex.Encode(res[1:], (*pe.Plain)[:])
|
||||
res[0] = '"'
|
||||
res[65] = '"'
|
||||
return res, nil
|
||||
} else if pe.Encrypted != nil {
|
||||
return json.Marshal(*pe.Encrypted)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no key to marshal")
|
||||
}
|
||||
|
||||
func (pe *plainOrEncryptedKey) UnmarshalJSON(buf []byte) error {
|
||||
if len(buf) == 66 {
|
||||
sk, err := nostr.SecretKeyFromHex(string(buf[1 : 1+64]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pe.Plain = &sk
|
||||
return nil
|
||||
} else if bytes.HasPrefix(buf, []byte("\"nsec")) {
|
||||
_, v, err := nip19.Decode(string(buf[1 : len(buf)-1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sk := v.(nostr.SecretKey)
|
||||
pe.Plain = &sk
|
||||
return nil
|
||||
} else if bytes.HasPrefix(buf, []byte("\"ncryptsec1")) {
|
||||
ncryptsec := string(buf[1 : len(buf)-1])
|
||||
pe.Encrypted = &ncryptsec
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unrecognized key format '%s'", string(buf))
|
||||
}
|
||||
|
||||
func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
||||
if a.Plain == nil && b.Plain != nil {
|
||||
return false
|
||||
}
|
||||
if a.Plain != nil && b.Plain == nil {
|
||||
return false
|
||||
}
|
||||
if a.Plain != nil && b.Plain != nil && *a.Plain != *b.Plain {
|
||||
return false
|
||||
}
|
||||
|
||||
if a.Encrypted == nil && b.Encrypted != nil {
|
||||
return false
|
||||
}
|
||||
if a.Encrypted != nil && b.Encrypted == nil {
|
||||
return false
|
||||
}
|
||||
if a.Encrypted != nil && b.Encrypted != nil && *a.Encrypted != *b.Encrypted {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func getSocketPath(c *cli.Command) string {
|
||||
profile := "default"
|
||||
if c.IsSet("profile") {
|
||||
profile = c.String("profile")
|
||||
}
|
||||
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
||||
}
|
||||
|
||||
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
||||
res := make(chan *url.URL)
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
// ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
log(color.RedString("failed to create socket directory: %s\n", err))
|
||||
return res
|
||||
}
|
||||
|
||||
// delete existing socket file if it exists
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
log(color.RedString("failed to remove existing socket file: %s\n", err))
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
|
||||
return res
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath) // cleanup socket file on exit
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
uri, err := url.Parse(string(buf[:n]))
|
||||
if err == nil && uri.Scheme == "nostrconnect" {
|
||||
res <- uri
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func sendToSocket(c *cli.Command, value string) error {
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send uri to bunker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
245
cli_test.go
Normal file
245
cli_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
|
||||
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
|
||||
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
|
||||
|
||||
func call(t *testing.T, cmd string) string {
|
||||
var output strings.Builder
|
||||
stdout = func(a ...any) {
|
||||
output.WriteString(fmt.Sprint(a...))
|
||||
output.WriteString("\n")
|
||||
}
|
||||
err := app.Run(t.Context(), strings.Split(cmd, " "))
|
||||
require.NoError(t, err)
|
||||
|
||||
return strings.TrimSpace(output.String())
|
||||
}
|
||||
|
||||
func TestEventBasic(t *testing.T) {
|
||||
output := call(t, "nak event --ts 1699485669")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(1), evt.Kind)
|
||||
require.Equal(t, nostr.Timestamp(1699485669), evt.CreatedAt)
|
||||
require.Equal(t, "hello from the nostr army knife", evt.Content)
|
||||
require.Equal(t, "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", evt.ID.Hex())
|
||||
require.Equal(t, "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", evt.PubKey.Hex())
|
||||
require.Equal(t, "68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2", hex.EncodeToString(evt.Sig[:]))
|
||||
}
|
||||
|
||||
func TestEventComplex(t *testing.T) {
|
||||
output := call(t, "nak event --ts 1699485669 -k 11 -c skjdbaskd --sec 17 -t t=spam -e 36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c -t r=https://abc.def?name=foobar;nothing")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(11), evt.Kind)
|
||||
require.Equal(t, nostr.Timestamp(1699485669), evt.CreatedAt)
|
||||
require.Equal(t, "skjdbaskd", evt.Content)
|
||||
require.Equal(t, "19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179", evt.ID.Hex())
|
||||
require.Equal(t, "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", evt.PubKey.Hex())
|
||||
require.Equal(t, "cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7", hex.EncodeToString(evt.Sig[:]))
|
||||
|
||||
require.Len(t, evt.Tags, 3)
|
||||
require.Equal(t, nostr.Tag{"t", "spam"}, evt.Tags[0])
|
||||
require.Equal(t, nostr.Tag{"r", "https://abc.def?name=foobar", "nothing"}, evt.Tags[1])
|
||||
require.Equal(t, nostr.Tag{"e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"}, evt.Tags[2])
|
||||
}
|
||||
|
||||
func TestEncode(t *testing.T) {
|
||||
require.Equal(t,
|
||||
"npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28",
|
||||
call(t, "nak encode npub a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"),
|
||||
)
|
||||
require.Equal(t,
|
||||
`nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
||||
nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a`,
|
||||
call(t, "nak encode nprofile -r wss://example.com a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822 a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestDecodeNaddr(t *testing.T) {
|
||||
output := call(t, "nak decode naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu")
|
||||
|
||||
var result map[string]interface{}
|
||||
err := stdjson.Unmarshal([]byte(output), &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e", result["pubkey"])
|
||||
require.Equal(t, float64(31923), result["kind"])
|
||||
require.Equal(t, "4cd6cfe7", result["identifier"])
|
||||
require.Equal(t, []interface{}{"wss://nos.lol"}, result["relays"])
|
||||
}
|
||||
|
||||
func TestDecodePubkey(t *testing.T) {
|
||||
output := call(t, "nak decode -p npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd")
|
||||
|
||||
expected := "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\nc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
|
||||
require.Equal(t, expected, output)
|
||||
}
|
||||
|
||||
func TestDecodeMultipleNpubs(t *testing.T) {
|
||||
output := call(t, "nak decode npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk")
|
||||
require.Len(t, strings.Split(output, "\n"), 2)
|
||||
}
|
||||
|
||||
func TestDecodeEventId(t *testing.T) {
|
||||
output := call(t, "nak decode -e nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8 nevent1qqswh48lurxs8u0pll9qj2rzctvjncwhstpzlstq59rdtzlty79awns5hl5uf")
|
||||
|
||||
expected := "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5\nebd4ffe0cd03f1e1ffca092862c2d929e1d782c22fc160a146d58beb278bd74e"
|
||||
require.Equal(t, expected, output)
|
||||
}
|
||||
|
||||
func TestReq(t *testing.T) {
|
||||
output := call(t, "nak req -k 1 -l 18 -a 2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f -e aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6")
|
||||
|
||||
var result []interface{}
|
||||
err := stdjson.Unmarshal([]byte(output), &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "REQ", result[0])
|
||||
require.Equal(t, "nak", result[1])
|
||||
|
||||
filter := result[2].(map[string]interface{})
|
||||
require.Equal(t, []interface{}{float64(1)}, filter["kinds"])
|
||||
require.Equal(t, []interface{}{"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"}, filter["authors"])
|
||||
require.Equal(t, float64(18), filter["limit"])
|
||||
require.Equal(t, []interface{}{"aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"}, filter["#e"])
|
||||
}
|
||||
|
||||
func TestMultipleFetch(t *testing.T) {
|
||||
output := call(t, "nak fetch naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8")
|
||||
|
||||
var events []nostr.Event
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(line), &evt)
|
||||
require.NoError(t, err)
|
||||
events = append(events, evt)
|
||||
}
|
||||
|
||||
require.Len(t, events, 2)
|
||||
|
||||
// first event validation
|
||||
require.Equal(t, nostr.Kind(31923), events[0].Kind)
|
||||
require.Equal(t, "9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05", events[0].ID.Hex())
|
||||
require.Equal(t, "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e", events[0].PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1707764605), events[0].CreatedAt)
|
||||
|
||||
// second event validation
|
||||
require.Equal(t, nostr.Kind(1), events[1].Kind)
|
||||
require.Equal(t, "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", events[1].ID.Hex())
|
||||
require.Equal(t, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", events[1].PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1710759386), events[1].CreatedAt)
|
||||
}
|
||||
|
||||
func TestKeyPublic(t *testing.T) {
|
||||
output := call(t, "nak key public 3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
|
||||
|
||||
expected := "70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8\n718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029"
|
||||
require.Equal(t, expected, output)
|
||||
}
|
||||
|
||||
func TestKeyDecrypt(t *testing.T) {
|
||||
output := call(t, "nak key decrypt ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft banana")
|
||||
require.Equal(t, "718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029", output)
|
||||
}
|
||||
|
||||
func TestReqIdFromRelay(t *testing.T) {
|
||||
output := call(t, "nak req -i 20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da --limit 1 nos.lol")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(1), evt.Kind)
|
||||
require.Equal(t, "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", evt.ID.Hex())
|
||||
require.Equal(t, "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", evt.PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1720972243), evt.CreatedAt)
|
||||
require.Equal(t, "Yeah, so bizarre, but I guess most people are meant to be serfs.", evt.Content)
|
||||
}
|
||||
|
||||
func TestReqWithFlagsAfter1(t *testing.T) {
|
||||
output := call(t, "nak req nos.lol -i 20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da --limit 1")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(1), evt.Kind)
|
||||
require.Equal(t, "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", evt.ID.Hex())
|
||||
require.Equal(t, "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", evt.PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1720972243), evt.CreatedAt)
|
||||
require.Equal(t, "Yeah, so bizarre, but I guess most people are meant to be serfs.", evt.Content)
|
||||
}
|
||||
|
||||
func TestReqWithFlagsAfter2(t *testing.T) {
|
||||
output := call(t, "nak req -e 893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1 nostr.mom --author 2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6 --limit 1 -k 7")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(7), evt.Kind)
|
||||
require.Equal(t, "9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857", evt.ID.Hex())
|
||||
require.Equal(t, "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", evt.PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1720987327), evt.CreatedAt)
|
||||
require.Equal(t, "❤️", evt.Content)
|
||||
}
|
||||
|
||||
func TestReqWithFlagsAfter3(t *testing.T) {
|
||||
output := call(t, "nak req --limit 1 pyramid.fiatjaf.com -a 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 -qp 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 -e 9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(1), evt.Kind)
|
||||
require.Equal(t, "101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67", evt.ID.Hex())
|
||||
require.Equal(t, "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", evt.PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1720987305), evt.CreatedAt)
|
||||
require.Equal(t, "Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.", evt.Content)
|
||||
}
|
||||
|
||||
func TestNaturalTimestamps(t *testing.T) {
|
||||
output := call(t, "nak event -t plu=pla -e 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 --ts '2018-May-19T03:37:19' -c nn")
|
||||
|
||||
var evt nostr.Event
|
||||
err := stdjson.Unmarshal([]byte(output), &evt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, nostr.Kind(1), evt.Kind)
|
||||
require.Equal(t, "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", evt.PubKey.Hex())
|
||||
require.Equal(t, nostr.Timestamp(1526711839), evt.CreatedAt)
|
||||
require.Equal(t, "nn", evt.Content)
|
||||
}
|
||||
|
||||
func TestEventJQ(t *testing.T) {
|
||||
// default: string results come out JSON-quoted, like plain jq
|
||||
quoted := call(t, "nak event --ts 1699485669 -c hello --jq .content")
|
||||
require.Equal(t, `"hello"`, quoted)
|
||||
|
||||
// --jq-raw: string results come out unquoted, like `jq -r`
|
||||
raw := call(t, "nak event --ts 1699485669 -c hello --jq .content --jq-raw")
|
||||
require.Equal(t, "hello", raw)
|
||||
|
||||
// --jq-raw on a non-string result is still JSON-encoded, like `jq -r`
|
||||
num := call(t, "nak event --ts 1699485669 -k 7 --jq .kind --jq-raw")
|
||||
require.Equal(t, "7", num)
|
||||
}
|
||||
218
count.go
218
count.go
@@ -6,73 +6,28 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip45"
|
||||
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip45"
|
||||
"fiatjaf.com/nostr/nip45/hyperloglog"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var count = &cli.Command{
|
||||
Name: "count",
|
||||
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
|
||||
Description: `outputs a NIP-45 request (the flags are mostly the same as 'nak req').`,
|
||||
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors (pubkey as hex)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "only accept events with these kind numbers",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "e",
|
||||
Usage: "shortcut for --tag e=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "p",
|
||||
Usage: "shortcut for --tag p=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "only accept events newer than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "until",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "only accept events older than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "only accept up to this number of events",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
},
|
||||
ArgsUsage: "[relay...]",
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags}),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
biggerUrlSize := 0
|
||||
relayUrls := c.Args().Slice()
|
||||
if len(relayUrls) > 0 {
|
||||
relays := connectToAllRelays(ctx,
|
||||
relays := connectToAllRelays(
|
||||
ctx,
|
||||
c,
|
||||
relayUrls,
|
||||
false,
|
||||
)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
@@ -85,105 +40,78 @@ var count = &cli.Command{
|
||||
biggerUrlSize = len(relay.URL)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, relay := range relays {
|
||||
relay.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
filter := nostr.Filter{}
|
||||
|
||||
if authors := c.StringSlice("author"); len(authors) > 0 {
|
||||
filter.Authors = authors
|
||||
}
|
||||
if ids := c.StringSlice("id"); len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
|
||||
kinds := make([]int, len(kinds64))
|
||||
for i, v := range kinds64 {
|
||||
kinds[i] = int(v)
|
||||
}
|
||||
filter.Kinds = kinds
|
||||
}
|
||||
|
||||
tags := make([][]string, 0, 5)
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
tags = append(tags, 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})
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
filter.Tags = make(nostr.TagMap)
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
if since := c.Int("since"); since != 0 {
|
||||
ts := nostr.Timestamp(since)
|
||||
filter.Since = &ts
|
||||
}
|
||||
if until := c.Int("until"); until != 0 {
|
||||
ts := nostr.Timestamp(until)
|
||||
filter.Until = &ts
|
||||
}
|
||||
if limit := c.Int("limit"); limit != 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
relay, _ := sys.Pool.EnsureRelay(relayUrl)
|
||||
count, hllRegisters, err := relay.Count(ctx, nostr.Filters{filter})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
|
||||
// go line by line from stdin or run once with input from flags
|
||||
for stdinFilter := range getJsonsOrBlank() {
|
||||
filter := nostr.Filter{}
|
||||
if stdinFilter != "" {
|
||||
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " 📋"
|
||||
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
nm := nostr.NormalizeURL(relayUrl)
|
||||
relay, ok := sys.Pool.Relays.Load(nm)
|
||||
if !ok || relay == nil || !relay.IsConnected() {
|
||||
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
var err error
|
||||
relay, err = nostr.RelayConnect(ct, relayUrl, sys.Pool.RelayOptions)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
sys.Pool.Relays.Store(nm, relay)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-count",
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " (hll)"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
137
curl.go
Normal file
137
curl.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var curlFlags []string
|
||||
|
||||
var curl = &cli.Command{
|
||||
Name: "curl",
|
||||
Usage: "calls curl but with a nip98 header",
|
||||
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
// this command is always run standalone (see realCurl()), never under the root
|
||||
// command, so it needs its own copy of the key flags
|
||||
Flags: defaultKeyFlags,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cowboy parsing of curl flags to get the data we need for nip98
|
||||
var url string
|
||||
var method string
|
||||
var presumedMethod string
|
||||
|
||||
curlBodyBuildingFlags := []string{
|
||||
"-d",
|
||||
"--data",
|
||||
"--data-binary",
|
||||
"--data-ascii",
|
||||
"--data-raw",
|
||||
"--data-urlencode",
|
||||
"-F",
|
||||
"--form",
|
||||
"--form-string",
|
||||
"--form-escape",
|
||||
"--upload-file",
|
||||
}
|
||||
|
||||
nextIsMethod := false
|
||||
for _, f := range curlFlags {
|
||||
if nextIsMethod {
|
||||
method = f
|
||||
method, _ = strings.CutPrefix(method, `"`)
|
||||
method, _ = strings.CutSuffix(method, `"`)
|
||||
method = strings.ToUpper(method)
|
||||
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
|
||||
url = f
|
||||
} else if f == "--request" || f == "-X" {
|
||||
nextIsMethod = true
|
||||
continue
|
||||
} else if slices.Contains(curlBodyBuildingFlags, f) ||
|
||||
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
|
||||
return strings.HasPrefix(f, s)
|
||||
}) {
|
||||
presumedMethod = "POST"
|
||||
}
|
||||
nextIsMethod = false
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return fmt.Errorf("can't create nip98 event: target url is empty")
|
||||
}
|
||||
|
||||
if method == "" {
|
||||
if presumedMethod != "" {
|
||||
method = presumedMethod
|
||||
} else {
|
||||
method = "GET"
|
||||
}
|
||||
}
|
||||
|
||||
// make and sign event
|
||||
evt := nostr.Event{
|
||||
Kind: 27235,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"u", url},
|
||||
{"method", method},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// the first 2 indexes of curlFlags were reserved for this
|
||||
curlFlags[0] = "-H"
|
||||
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
|
||||
|
||||
// call curl
|
||||
cmd := exec.Command("curl", curlFlags...)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Run()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func realCurl() error {
|
||||
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
|
||||
keyFlags := make([]string, 0, 5)
|
||||
|
||||
for i := 0; i < len(os.Args[2:]); i++ {
|
||||
arg := os.Args[i+2]
|
||||
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
|
||||
bareArg, _ := strings.CutPrefix(arg, "-")
|
||||
bareArg, _ = strings.CutPrefix(bareArg, "-")
|
||||
return slices.Contains(f.Names(), bareArg)
|
||||
}) {
|
||||
keyFlags = append(keyFlags, arg)
|
||||
if arg != "--prompt-sec" {
|
||||
i++
|
||||
if i+2 >= len(os.Args) {
|
||||
break
|
||||
}
|
||||
keyFlags = append(keyFlags, os.Args[i+2])
|
||||
}
|
||||
} else {
|
||||
curlFlags = append(curlFlags, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Run treats the first element as the program name, so prepend one
|
||||
return curl.Run(context.Background(), append([]string{"nak curl"}, keyFlags...))
|
||||
}
|
||||
119
decode.go
119
decode.go
@@ -3,12 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
stdjson "encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"github.com/nbd-wtf/go-nostr/sdk"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var decode = &cli.Command{
|
||||
@@ -39,88 +40,56 @@ var decode = &cli.Command{
|
||||
input = input[6:]
|
||||
}
|
||||
|
||||
var decodeResult DecodeResult
|
||||
if b, err := hex.DecodeString(input); err == nil {
|
||||
if len(b) == 64 {
|
||||
decodeResult.HexResult.PossibleTypes = []string{"sig"}
|
||||
decodeResult.HexResult.Signature = hex.EncodeToString(b)
|
||||
} else if len(b) == 32 {
|
||||
decodeResult.HexResult.PossibleTypes = []string{"pubkey", "private_key", "event_id"}
|
||||
decodeResult.HexResult.ID = hex.EncodeToString(b)
|
||||
decodeResult.HexResult.PrivateKey = hex.EncodeToString(b)
|
||||
decodeResult.HexResult.PublicKey = hex.EncodeToString(b)
|
||||
} else {
|
||||
ctx = lineProcessingError(ctx, "hex string with invalid number of bytes: %d", len(b))
|
||||
_, data, err := nip19.Decode(input)
|
||||
if err == nil {
|
||||
switch v := data.(type) {
|
||||
case nostr.SecretKey:
|
||||
stdout(v.Hex())
|
||||
continue
|
||||
case nostr.PubKey:
|
||||
stdout(v.Hex())
|
||||
continue
|
||||
case [32]byte:
|
||||
stdout(hex.EncodeToString(v[:]))
|
||||
continue
|
||||
case nostr.EventPointer:
|
||||
if c.Bool("id") {
|
||||
stdout(v.ID.Hex())
|
||||
continue
|
||||
}
|
||||
out, _ := stdjson.MarshalIndent(v, "", " ")
|
||||
stdout(string(out))
|
||||
continue
|
||||
case nostr.ProfilePointer:
|
||||
if c.Bool("pubkey") {
|
||||
stdout(v.PublicKey.Hex())
|
||||
continue
|
||||
}
|
||||
out, _ := stdjson.MarshalIndent(v, "", " ")
|
||||
stdout(string(out))
|
||||
continue
|
||||
case nostr.EntityPointer:
|
||||
out, _ := stdjson.MarshalIndent(v, "", " ")
|
||||
stdout(string(out))
|
||||
continue
|
||||
}
|
||||
} else if evp := sdk.InputToEventPointer(input); evp != nil {
|
||||
decodeResult = DecodeResult{EventPointer: evp}
|
||||
if c.Bool("id") {
|
||||
stdout(evp.ID)
|
||||
continue
|
||||
}
|
||||
} else if pp := sdk.InputToProfile(ctx, input); pp != nil {
|
||||
decodeResult = DecodeResult{ProfilePointer: pp}
|
||||
}
|
||||
|
||||
pp, _ := nip05.QueryIdentifier(ctx, input)
|
||||
if pp != nil {
|
||||
if c.Bool("pubkey") {
|
||||
stdout(pp.PublicKey)
|
||||
stdout(pp.PublicKey.Hex())
|
||||
continue
|
||||
}
|
||||
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "naddr" {
|
||||
if ep, ok := value.(nostr.EntityPointer); ok {
|
||||
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" {
|
||||
decodeResult.PrivateKey.PrivateKey = value.(string)
|
||||
decodeResult.PrivateKey.PublicKey, _ = nostr.GetPublicKey(value.(string))
|
||||
} else {
|
||||
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
|
||||
out, _ := stdjson.MarshalIndent(pp, "", " ")
|
||||
stdout(string(out))
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Bool("pubkey") || c.Bool("id") {
|
||||
return nil
|
||||
}
|
||||
|
||||
stdout(decodeResult.JSON())
|
||||
|
||||
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type DecodeResult struct {
|
||||
*nostr.EventPointer
|
||||
*nostr.ProfilePointer
|
||||
*nostr.EntityPointer
|
||||
HexResult struct {
|
||||
PossibleTypes []string `json:"possible_types"`
|
||||
PublicKey string `json:"pubkey,omitempty"`
|
||||
ID string `json:"event_id,omitempty"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
Signature string `json:"sig,omitempty"`
|
||||
}
|
||||
PrivateKey struct {
|
||||
nostr.ProfilePointer
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
}
|
||||
|
||||
func (d DecodeResult) JSON() string {
|
||||
var j []byte
|
||||
if d.EventPointer != nil {
|
||||
j, _ = json.MarshalIndent(d.EventPointer, "", " ")
|
||||
} else if d.ProfilePointer != nil {
|
||||
j, _ = json.MarshalIndent(d.ProfilePointer, "", " ")
|
||||
} else if d.EntityPointer != nil {
|
||||
j, _ = json.MarshalIndent(d.EntityPointer, "", " ")
|
||||
} else if len(d.HexResult.PossibleTypes) > 0 {
|
||||
j, _ = json.MarshalIndent(d.HexResult, "", " ")
|
||||
} else if d.PrivateKey.PrivateKey != "" {
|
||||
j, _ = json.MarshalIndent(d.PrivateKey, "", " ")
|
||||
}
|
||||
return string(j)
|
||||
}
|
||||
|
||||
437
dekey.go
Normal file
437
dekey.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var dekey = &cli.Command{
|
||||
Name: "dekey",
|
||||
Usage: "handles NIP-4E decoupled encryption keys",
|
||||
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Usage: "name of this device that will be published and displayed on other clients",
|
||||
Value: func() string {
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
return "nak@" + hostname
|
||||
}
|
||||
return "nak@unknown"
|
||||
}(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "rotate",
|
||||
Usage: "force the creation of a new decoupled encryption key, effectively invalidating any previous ones",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "authorize-all",
|
||||
Aliases: []string{"yolo"},
|
||||
Usage: "do not ask for confirmation, just automatically send the decoupled encryption key to all devices that exist",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "reject-all",
|
||||
Usage: "do not ask for confirmation, just not send the decoupled encryption key to any device",
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userPub, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user public key: %w", err)
|
||||
}
|
||||
|
||||
configPath := c.String("config-path")
|
||||
deviceName := c.String("device")
|
||||
|
||||
log("handling device key for %s as %s\n",
|
||||
color.YellowString(deviceName),
|
||||
color.CyanString(nip19.EncodeNpub(userPub)),
|
||||
)
|
||||
// check if we already have a local-device secret key
|
||||
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
|
||||
var deviceSec nostr.SecretKey
|
||||
if data, err := os.ReadFile(deviceKeyPath); err == nil {
|
||||
log(color.GreenString("found existing device key\n"))
|
||||
deviceSec, err = nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
|
||||
}
|
||||
} else {
|
||||
log(color.YellowString("generating new device key\n"))
|
||||
// create one
|
||||
deviceSec = nostr.Generate()
|
||||
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
|
||||
if err := os.WriteFile(deviceKeyPath, []byte(deviceSec.Hex()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write device key: %w", err)
|
||||
}
|
||||
log(color.GreenString("device key generated and stored\n"))
|
||||
}
|
||||
devicePub := deviceSec.Public()
|
||||
|
||||
// get relays for the user
|
||||
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
|
||||
relays := sys.FetchWriteRelays(ctx, userPub)
|
||||
relayList := connectToAllRelays(ctx, c, relays)
|
||||
if len(relayList) == 0 {
|
||||
return fmt.Errorf("no relays to use")
|
||||
}
|
||||
|
||||
// check for kind:10044
|
||||
log("- checking for decoupled encryption key (kind:10044)\n")
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
||||
var eSec nostr.SecretKey
|
||||
var ePub nostr.PubKey
|
||||
|
||||
var generateNewEncryptionKey bool
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""})
|
||||
if !ok {
|
||||
log("- no decoupled encryption key found, generating new one\n")
|
||||
generateNewEncryptionKey = true
|
||||
} else {
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||
}
|
||||
|
||||
log(". a decoupled encryption public key already exists: %s\n", color.CyanString(ePub.Hex()))
|
||||
if c.Bool("rotate") {
|
||||
log(color.GreenString("rotating it by generating a new one\n"))
|
||||
generateNewEncryptionKey = true
|
||||
}
|
||||
}
|
||||
|
||||
if generateNewEncryptionKey {
|
||||
// generate main secret key
|
||||
eSec = nostr.Generate()
|
||||
ePub = eSec.Public()
|
||||
|
||||
// store it
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write decoupled encryption key: %w", err)
|
||||
}
|
||||
log("decoupled encryption key generated and stored, public key: %s\n", color.CyanString(ePub.Hex()))
|
||||
|
||||
// publish kind:10044
|
||||
log("publishing decoupled encryption public key (kind:10044)\n")
|
||||
evt10044 := nostr.Event{
|
||||
Kind: 10044,
|
||||
Content: "",
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"n", ePub.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt10044); err != nil {
|
||||
return fmt.Errorf("failed to sign kind:10044: %w", err)
|
||||
}
|
||||
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// check if we have the key
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||
log(color.GreenString("- and we have it locally already\n"))
|
||||
eSec, err = nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
|
||||
}
|
||||
} else {
|
||||
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
|
||||
|
||||
// check if our kind:4454 is already published
|
||||
log("- checking for existing device announcement (kind:4454)\n")
|
||||
ourDeviceAnnouncementEvents := make([]nostr.Event, 0, 1)
|
||||
for evt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4454},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
Tags: nostr.TagMap{
|
||||
"P": []string{devicePub.Hex()},
|
||||
},
|
||||
Limit: 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt.Event)
|
||||
}
|
||||
if len(ourDeviceAnnouncementEvents) == 0 {
|
||||
log(". no device announcement found, publishing kind:4454 for %s\n", color.YellowString(deviceName))
|
||||
// publish kind:4454
|
||||
evt := nostr.Event{
|
||||
Kind: 4454,
|
||||
Content: "",
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"client", deviceName},
|
||||
{"P", devicePub.Hex()},
|
||||
},
|
||||
}
|
||||
|
||||
// sign with main key
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("failed to sign device event: %w", err)
|
||||
}
|
||||
|
||||
// publish
|
||||
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
|
||||
return err
|
||||
}
|
||||
log(color.GreenString(". device announcement published\n"))
|
||||
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt)
|
||||
} else {
|
||||
log(color.GreenString(". device already registered\n"))
|
||||
}
|
||||
|
||||
// see if some other device has shared the key with us from kind:4455
|
||||
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4455},
|
||||
Tags: nostr.TagMap{
|
||||
"p": []string{devicePub.Hex()},
|
||||
},
|
||||
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
var senderPub nostr.PubKey
|
||||
for _, tag := range eKeyMsg.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "P" {
|
||||
senderPub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if senderPub == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
ss, err := nip44.GenerateConversationKey(senderPub, deviceSec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eSecHex, err := nip44.Decrypt(eKeyMsg.Content, ss)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
candidate, err := nostr.SecretKeyFromHex(eSecHex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// check if it matches mainPub -- only keep it if it does, otherwise a
|
||||
// stale key received from another device would end up being redistributed
|
||||
if candidate.Public() == ePub {
|
||||
eSec = candidate
|
||||
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
|
||||
// store it
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
||||
|
||||
// delete our 4454 if we had one, since we received the key
|
||||
if len(ourDeviceAnnouncementEvents) > 0 {
|
||||
log("deleting our device announcement (kind:4454) since we received the decoupled encryption key\n")
|
||||
deletion4454 := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", ourDeviceAnnouncementEvents[0].ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion4454); err != nil {
|
||||
log(color.RedString("failed to sign 4454 deletion: %v\n"), err)
|
||||
} else if err := publishFlow(ctx, c, kr, deletion4454, relayList); err != nil {
|
||||
log(color.RedString("failed to publish 4454 deletion: %v\n"), err)
|
||||
} else {
|
||||
log(color.GreenString("- device announcement deleted\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// delete the 4455 we just decrypted
|
||||
log("deleting the key message (kind:4455) we just decrypted\n")
|
||||
deletion4455 := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", eKeyMsg.ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion4455); err != nil {
|
||||
log(color.RedString("failed to sign 4455 deletion: %v\n"), err)
|
||||
} else if err := publishFlow(ctx, c, kr, deletion4455, relayList); err != nil {
|
||||
log(color.RedString("failed to publish 4455 deletion: %v\n"), err)
|
||||
} else {
|
||||
log(color.GreenString("- key message deleted\n"))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if eSec == [32]byte{} {
|
||||
log("decoupled encryption secret key not available, must be sent from another device to %s first\n",
|
||||
color.YellowString(deviceName))
|
||||
return nil
|
||||
}
|
||||
log(color.GreenString("- decoupled encryption key ready\n"))
|
||||
|
||||
// now we have mainSec, check for other kind:4454 events newer than the 10044
|
||||
log("- checking for other devices and key messages so we can send the key\n")
|
||||
keyMsgs := make([]string, 0, 5)
|
||||
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4454, 4455},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
if keyOrDeviceEvt.Kind == 4455 {
|
||||
// got key event
|
||||
keyEvent := keyOrDeviceEvt
|
||||
|
||||
// assume a key msg will always come before its associated devicemsg
|
||||
// so just store them here:
|
||||
pubkeyTag := keyEvent.Tags.Find("p")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
keyMsgs = append(keyMsgs, pubkeyTag[1])
|
||||
} else if keyOrDeviceEvt.Kind == 4454 {
|
||||
// device event
|
||||
deviceEvt := keyOrDeviceEvt
|
||||
|
||||
// skip ourselves
|
||||
if deviceEvt.Tags.FindWithValue("P", devicePub.Hex()) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// if there is a clock skew (current time is earlier than the time of this device's announcement) skip it
|
||||
if nostr.Now() < deviceEvt.CreatedAt {
|
||||
continue
|
||||
}
|
||||
|
||||
// if this already has a corresponding keyMsg then skip it
|
||||
pubkeyTag := deviceEvt.Tags.Find("P")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceTag := deviceEvt.Tags.Find("client")
|
||||
if deviceTag == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
|
||||
// so we have to build a keyMsg for them
|
||||
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Bool("authorize-all") {
|
||||
// will proceed
|
||||
} else if c.Bool("reject-all") {
|
||||
log(" - skipping %s\n", color.YellowString(deviceTag[1]))
|
||||
continue
|
||||
} else {
|
||||
var proceed bool
|
||||
if err := survey.AskOne(&survey.Confirm{
|
||||
Message: fmt.Sprintf("share decoupled encryption key with %s"+colors.bold("?"),
|
||||
color.YellowString(deviceTag[1])),
|
||||
}, &proceed); err != nil {
|
||||
return err
|
||||
}
|
||||
if proceed {
|
||||
// will proceed
|
||||
} else {
|
||||
// won't proceed
|
||||
var deleteDevice bool
|
||||
if err := survey.AskOne(&survey.Confirm{
|
||||
Message: fmt.Sprintf(" delete %s"+colors.bold("'s announcement?"), color.YellowString(deviceTag[1])),
|
||||
}, &deleteDevice); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteDevice {
|
||||
log(" - deleting %s\n", color.YellowString(deviceTag[1]))
|
||||
deletion := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", deviceEvt.ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion); err != nil {
|
||||
return fmt.Errorf("failed to sign deletion '%s': %w", deletion.GetID().Hex(), err)
|
||||
}
|
||||
if err := publishFlow(ctx, c, kr, deletion, relayList); err != nil {
|
||||
return fmt.Errorf("publish flow failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
log(" - skipped\n")
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log("- sending decoupled encryption key to new device %s\n", color.YellowString(deviceTag[1]))
|
||||
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ciphertext, err := nip44.Encrypt(eSec.Hex(), ss)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
evt4455 := nostr.Event{
|
||||
Kind: 4455,
|
||||
Content: ciphertext,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"p", theirDevice.Hex()},
|
||||
{"P", devicePub.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt4455); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
|
||||
log(color.RedString("failed to publish key message: %v\n"), err)
|
||||
} else {
|
||||
log(" - decoupled encryption key sent to %s\n", color.GreenString(deviceTag[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdout(ePub.Hex())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
338
encode.go
338
encode.go
@@ -2,13 +2,67 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func parseProfilePointerInput(target string) (nostr.ProfilePointer, bool) {
|
||||
var profilePtr nostr.ProfilePointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &profilePtr); err != nil || profilePtr.PublicKey == nostr.ZeroPK {
|
||||
return nostr.ProfilePointer{}, false
|
||||
}
|
||||
return profilePtr, true
|
||||
}
|
||||
|
||||
func parseEventPointerInput(target string) (nostr.EventPointer, bool) {
|
||||
var eventPtr nostr.EventPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &eventPtr); err != nil || eventPtr.ID == nostr.ZeroID {
|
||||
return nostr.EventPointer{}, false
|
||||
}
|
||||
return eventPtr, true
|
||||
}
|
||||
|
||||
func parseEntityPointerInput(target string) (nostr.EntityPointer, bool) {
|
||||
var entityPtr nostr.EntityPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &entityPtr); err != nil || entityPtr.PublicKey == nostr.ZeroPK || entityPtr.Kind == 0 {
|
||||
return nostr.EntityPointer{}, false
|
||||
}
|
||||
return entityPtr, true
|
||||
}
|
||||
|
||||
func getEncodeSubcommandInput(args cli.Args, allowBlank bool) iter.Seq[string] {
|
||||
if args.Len() > 0 {
|
||||
return func(yield func(string) bool) {
|
||||
for _, arg := range args.Slice() {
|
||||
if !yield(arg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(yield func(string) bool) {
|
||||
for jsonStr := range getJsonsOrBlank() {
|
||||
if jsonStr == "{}" {
|
||||
if allowBlank {
|
||||
yield("")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(jsonStr) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var encode = &cli.Command{
|
||||
Name: "encode",
|
||||
Usage: "encodes notes and other stuff to nip19 entities",
|
||||
@@ -18,14 +72,63 @@ var encode = &cli.Command{
|
||||
nak encode nprofile --relay <relay-url> <pubkey-hex>
|
||||
nak encode nevent <event-id>
|
||||
nak encode nevent --author <pubkey-hex> --relay <relay-url> --relay <other-relay> <event-id>
|
||||
nak encode nsec <privkey-hex>`,
|
||||
Before: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() < 1 {
|
||||
return fmt.Errorf("expected more than 1 argument.")
|
||||
nak encode nsec <privkey-hex>
|
||||
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
|
||||
echo '{
|
||||
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19",
|
||||
"relays":["wss://nada.zero"],
|
||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||
} | nak encode`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 0 {
|
||||
switch c.Args().First() {
|
||||
case "naddr", "nevent", "npub", "nprofile", "nsec":
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasStdin := false
|
||||
for jsonStr := range getJsonsOrBlank() {
|
||||
if jsonStr == "{}" {
|
||||
hasStdin = false
|
||||
continue
|
||||
} else {
|
||||
hasStdin = true
|
||||
}
|
||||
|
||||
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
|
||||
continue
|
||||
}
|
||||
|
||||
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
ctx = lineProcessingError(ctx, "couldn't decode JSON '%s'", jsonStr)
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
return nil
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "npub",
|
||||
@@ -33,16 +136,13 @@ var encode = &cli.Command{
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if ok := nostr.IsValidPublicKey(target); !ok {
|
||||
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
|
||||
pk, err := nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if npub, err := nip19.EncodePublicKey(target); err == nil {
|
||||
stdout(npub)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
stdout(nip19.EncodeNpub(pk))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
@@ -55,16 +155,13 @@ var encode = &cli.Command{
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||
ctx = lineProcessingError(ctx, "invalid private key: %s", target)
|
||||
sk, err := nostr.SecretKeyFromHex(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if npub, err := nip19.EncodePrivateKey(target); err == nil {
|
||||
stdout(npub)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
stdout(nip19.EncodeNsec(sk))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
@@ -78,27 +175,43 @@ var encode = &cli.Command{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to nprofile code",
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
|
||||
continue
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
relays := c.StringSlice("relay")
|
||||
pk := nostr.ZeroPK
|
||||
|
||||
if profilePtr, ok := parseProfilePointerInput(target); ok {
|
||||
pk = profilePtr.PublicKey
|
||||
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
|
||||
} else {
|
||||
var err error
|
||||
pk, err = nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if npub, err := nip19.EncodeProfile(target, relays); err == nil {
|
||||
stdout(npub)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
stdout(nip19.EncodeNprofile(pk, relays))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
@@ -109,42 +222,55 @@ var encode = &cli.Command{
|
||||
Name: "nevent",
|
||||
Usage: "generate event codes with optionally attached relay information",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to nevent code",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
&PubKeyFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if ok := nostr.IsValid32ByteHex(target); !ok {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
id := nostr.ZeroID
|
||||
author := getPubKey(c, "author")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
author := c.String("author")
|
||||
if author != "" {
|
||||
if ok := nostr.IsValidPublicKey(author); !ok {
|
||||
return fmt.Errorf("invalid 'author' public key")
|
||||
if eventPtr, ok := parseEventPointerInput(target); ok {
|
||||
id = eventPtr.ID
|
||||
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
|
||||
if author == nostr.ZeroPK {
|
||||
author = eventPtr.Author
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
id, err = parseEventID(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if npub, err := nip19.EncodeEvent(target, relays, author); err == nil {
|
||||
stdout(npub)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
stdout(nip19.EncodeNevent(id, relays, author))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
@@ -153,63 +279,103 @@ var encode = &cli.Command{
|
||||
},
|
||||
{
|
||||
Name: "naddr",
|
||||
Usage: "generate codes for NIP-33 parameterized replaceable events",
|
||||
Usage: "generate codes for addressable events",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
Required: true,
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
Required: true,
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
Required: true,
|
||||
&KindFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to naddr code",
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for d := range getStdinLinesOrBlank() {
|
||||
pubkey := c.String("pubkey")
|
||||
if ok := nostr.IsValidPublicKey(pubkey); !ok {
|
||||
return fmt.Errorf("invalid 'pubkey'")
|
||||
for target := range getEncodeSubcommandInput(c.Args(), true) {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
kind := getKind(c, "kind")
|
||||
d := c.String("identifier")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if entityPtr, ok := parseEntityPointerInput(target); ok {
|
||||
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
|
||||
if pubkey == nostr.ZeroPK {
|
||||
pubkey = entityPtr.PublicKey
|
||||
}
|
||||
if kind == 0 {
|
||||
kind = entityPtr.Kind
|
||||
}
|
||||
if !c.IsSet("identifier") {
|
||||
d = entityPtr.Identifier
|
||||
}
|
||||
} else if target != "" {
|
||||
d = target
|
||||
}
|
||||
|
||||
kind := c.Int("kind")
|
||||
if kind < 30000 || kind >= 40000 {
|
||||
return fmt.Errorf("kind must be between 30000 and 39999, as per NIP-16, got %d", kind)
|
||||
if pubkey == nostr.ZeroPK {
|
||||
ctx = lineProcessingError(ctx, "pubkey must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if d == "" {
|
||||
d = c.String("identifier")
|
||||
if kind == 0 {
|
||||
ctx = lineProcessingError(ctx, "kind must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if kind.IsAddressable() {
|
||||
if d == "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
|
||||
continue
|
||||
}
|
||||
} else if kind.IsReplaceable() {
|
||||
if d != "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
ctx = lineProcessingError(ctx, "can only encode addressable events")
|
||||
continue
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if npub, err := nip19.EncodeEntity(pubkey, int(kind), d, relays); err == nil {
|
||||
stdout(npub)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
stdout(nip19.EncodeNaddr(pubkey, nostr.Kind(kind), d, relays))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip04"
|
||||
"fiatjaf.com/nostr/nip04"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var encrypt = &cli.Command{
|
||||
@@ -14,23 +13,19 @@ var encrypt = &cli.Command{
|
||||
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
|
||||
ArgsUsage: "[plaintext string]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey"},
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "nip04",
|
||||
Usage: "use nip04 encryption instead of nip44",
|
||||
},
|
||||
),
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
target := c.String("recipient-pubkey")
|
||||
if !nostr.IsValidPublicKey(target) {
|
||||
return fmt.Errorf("target %s is not a valid public key", target)
|
||||
}
|
||||
target := getPubKey(c, "recipient-pubkey")
|
||||
|
||||
plaintext := c.Args().First()
|
||||
|
||||
@@ -79,23 +74,19 @@ var decrypt = &cli.Command{
|
||||
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
|
||||
ArgsUsage: "[ciphertext base64]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "sender-pubkey",
|
||||
Aliases: []string{"p", "src", "source", "pubkey"},
|
||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "nip04",
|
||||
Usage: "use nip04 encryption instead of nip44",
|
||||
},
|
||||
),
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
source := c.String("sender-pubkey")
|
||||
if !nostr.IsValidPublicKey(source) {
|
||||
return fmt.Errorf("source %s is not a valid public key", source)
|
||||
}
|
||||
source := getPubKey(c, "sender-pubkey")
|
||||
|
||||
ciphertext := c.Args().First()
|
||||
|
||||
@@ -118,7 +109,7 @@ var decrypt = &cli.Command{
|
||||
}
|
||||
plaintext, err := nip04.Decrypt(ciphertext, ss)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt as nip04: %w", err)
|
||||
return fmt.Errorf("failed to decrypt as nip04: %w", err)
|
||||
}
|
||||
stdout(plaintext)
|
||||
}
|
||||
@@ -130,7 +121,7 @@ var decrypt = &cli.Command{
|
||||
|
||||
res, err := kr.Decrypt(ctx, ciphertext, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt: %w", err)
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
stdout(res)
|
||||
}
|
||||
|
||||
450
event.go
450
event.go
@@ -2,26 +2,31 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip13"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip13"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
|
||||
CATEGORY_SIGNER = "SIGNER OPTIONS"
|
||||
CATEGORY_EXTRAS = "EXTRAS"
|
||||
CATEGORY_AUTH = "AUTH"
|
||||
)
|
||||
|
||||
var event = &cli.Command{
|
||||
var eventCmd = &cli.Command{
|
||||
Name: "event",
|
||||
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
|
||||
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
|
||||
@@ -36,7 +41,7 @@ example:
|
||||
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
|
||||
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
// ~ these args are only for the convoluted musig2 signing process
|
||||
// they will be generally copy-shared-pasted across some manual coordination method between participants
|
||||
&cli.UintFlag{
|
||||
@@ -63,9 +68,21 @@ example:
|
||||
Hidden: true,
|
||||
},
|
||||
// ~~~
|
||||
&cli.BoolFlag{
|
||||
Name: "force-sign",
|
||||
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-sign",
|
||||
Usage: "print the event without signing it, using the specified pubkey",
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "pow",
|
||||
Usage: "NIP-13 difficulty to target when doing hash work on the event id",
|
||||
Usage: "nip13 difficulty to target when doing hash work on the event id",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@@ -73,9 +90,14 @@ example:
|
||||
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@@ -83,18 +105,30 @@ example:
|
||||
Usage: "print the nevent code (to stderr) after the event is published",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&KindFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "event kind",
|
||||
Usage: "event kind number or name",
|
||||
DefaultText: "1",
|
||||
Value: 0,
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "set the event pubkey or add an 'a' tag if it's an address",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "content",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "event content",
|
||||
Usage: "event content (if it starts with an '@' will read from a file)",
|
||||
DefaultText: "hello from the nostr army knife",
|
||||
Value: "",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
@@ -120,6 +154,11 @@ example:
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "created-at",
|
||||
Aliases: []string{"time", "ts"},
|
||||
@@ -128,51 +167,60 @@ example:
|
||||
Value: nostr.Now(),
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "confirm",
|
||||
Usage: "ask before publishing the event",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// try to connect to the relays here
|
||||
var relays []*nostr.Relay
|
||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, relayUrls, false)
|
||||
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()
|
||||
}
|
||||
}()
|
||||
argRelayUrls := c.Args().Slice()
|
||||
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doAuth := c.Bool("auth")
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// then process input and generate events
|
||||
for stdinEvent := range getStdinLinesOrBlank() {
|
||||
evt := nostr.Event{
|
||||
Tags: make(nostr.Tags, 0, 3),
|
||||
}
|
||||
// then process input and generate events:
|
||||
|
||||
kindWasSupplied := false
|
||||
// will reuse this
|
||||
var evt nostr.Event
|
||||
|
||||
// this is called when we have a valid json from stdin
|
||||
handleEvent := func(stdinEvent string) error {
|
||||
evt.Content = ""
|
||||
evt.CreatedAt = 0
|
||||
evt.Tags = evt.Tags[:0]
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.PubKey = nostr.ZeroPK
|
||||
evt.Sig = [64]byte{}
|
||||
|
||||
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
|
||||
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
|
||||
mustRehashAndResign := false
|
||||
|
||||
if stdinEvent != "" {
|
||||
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event received from stdin: %s", err)
|
||||
continue
|
||||
}
|
||||
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
|
||||
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
return fmt.Errorf("invalid event received from stdin: %s", err)
|
||||
}
|
||||
|
||||
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
|
||||
evt.Kind = int(kind)
|
||||
if c.IsSet("kind") {
|
||||
evt.Kind = getKind(c, "kind")
|
||||
mustRehashAndResign = true
|
||||
} else if !kindWasSupplied {
|
||||
evt.Kind = 1
|
||||
@@ -180,9 +228,18 @@ example:
|
||||
}
|
||||
|
||||
if c.IsSet("content") {
|
||||
evt.Content = c.String("content")
|
||||
content := c.String("content")
|
||||
if strings.HasPrefix(content, "@") {
|
||||
filedata, err := os.ReadFile(content[1:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file '%s' for content: %w", content[1:], err)
|
||||
}
|
||||
evt.Content = string(filedata)
|
||||
} else {
|
||||
evt.Content = content
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
} else if evt.Content == "" && evt.Kind == 1 {
|
||||
} else if !contentWasSupplied && evt.Content == "" && evt.Kind == 1 {
|
||||
evt.Content = "hello from the nostr army knife"
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
@@ -196,19 +253,59 @@ example:
|
||||
if found {
|
||||
// tags may also contain extra elements separated with a ";"
|
||||
tagValues := strings.Split(tagValue, ";")
|
||||
val := tagValues[0]
|
||||
if len(tagName) == 1 {
|
||||
val = decodeTagValue(val, rune(tagName[0]))
|
||||
}
|
||||
if len(tagValues) >= 1 {
|
||||
tagValues[0] = val
|
||||
}
|
||||
tag = append(tag, tagValues...)
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = tags.AppendUnique([]string{"e", etag})
|
||||
decodedEtag := decodeTagValue(etag, 'e')
|
||||
if tags.FindWithValue("e", decodedEtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"e", decodedEtag})
|
||||
}
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = tags.AppendUnique([]string{"p", ptag})
|
||||
decodedPtag := decodeTagValue(ptag, 'p')
|
||||
if tags.FindWithValue("p", decodedPtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"p", decodedPtag})
|
||||
}
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
tags = tags.AppendUnique([]string{"d", dtag})
|
||||
if tags.FindWithValue("d", dtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"d", dtag})
|
||||
}
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
if tags.FindWithValue("h", htag) == nil {
|
||||
tags = append(tags, nostr.Tag{"h", htag})
|
||||
}
|
||||
}
|
||||
|
||||
var authorPubKey nostr.PubKey
|
||||
for _, a := range getPubKeyOrAddressSlice(c, "author") {
|
||||
// is it an address?
|
||||
if a.Addr != nil {
|
||||
aTag := a.Addr.AsTagReference()
|
||||
if tags.FindWithValue("a", aTag) == nil {
|
||||
tags = append(tags, nostr.Tag{"a", aTag})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if a.PubKey != nostr.ZeroPK {
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
return fmt.Errorf("multiple author pubkeys provided")
|
||||
}
|
||||
authorPubKey = a.PubKey
|
||||
}
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
@@ -216,6 +313,9 @@ example:
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
evt.PubKey = authorPubKey
|
||||
}
|
||||
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
@@ -225,7 +325,7 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
|
||||
if c.IsSet("musig") || c.Bool("force-sign") {
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
@@ -240,7 +340,7 @@ example:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||
}
|
||||
|
||||
@@ -251,7 +351,13 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if evt.Sig == "" || mustRehashAndResign {
|
||||
if c.Bool("no-sign") {
|
||||
if evt.PubKey == nostr.ZeroPK {
|
||||
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
|
||||
}
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.Sig = [64]byte{}
|
||||
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
|
||||
if numSigners := c.Uint("musig"); numSigners > 1 {
|
||||
// must do musig
|
||||
pubkeys := c.StringSlice("musig-pubkey")
|
||||
@@ -269,64 +375,220 @@ example:
|
||||
return nil
|
||||
}
|
||||
} else if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
if _, isBunker := kr.(keyer.BunkerSigner); isBunker && errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
err = fmt.Errorf("timeout waiting for bunker to respond")
|
||||
}
|
||||
return fmt.Errorf("error signing with provided key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// print event as json
|
||||
var result string
|
||||
if c.Bool("envelope") {
|
||||
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
||||
result = string(j)
|
||||
} else {
|
||||
j, _ := easyjson.Marshal(&evt)
|
||||
result = string(j)
|
||||
}
|
||||
stdout(result)
|
||||
|
||||
// publish to relays
|
||||
successRelays := make([]string, 0, len(relays))
|
||||
if len(relays) > 0 {
|
||||
os.Stdout.Sync()
|
||||
for _, relay := range relays {
|
||||
publish:
|
||||
log("publishing to %s... ", relay.URL)
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := relay.Publish(ctx, evt)
|
||||
if err == nil {
|
||||
// published fine
|
||||
log("success.\n")
|
||||
successRelays = append(successRelays, relay.URL)
|
||||
continue // continue to next relay
|
||||
var relays []*nostr.Relay
|
||||
// start from the given relays on every event so --outbox additions
|
||||
// for one event don't leak into the next
|
||||
relayUrls := slices.Clone(argRelayUrls)
|
||||
if len(relayUrls) > 0 || c.Bool("outbox") {
|
||||
if c.Bool("outbox") {
|
||||
if evt.PubKey != nostr.ZeroPK {
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchWriteRelays(ctx, evt.PubKey)...)
|
||||
}
|
||||
|
||||
// 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)
|
||||
log("performing auth as %s... ", pk)
|
||||
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)
|
||||
seenPubkeys := make(map[nostr.PubKey]struct{}, len(evt.Tags))
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") {
|
||||
continue
|
||||
}
|
||||
pk, err := nostr.PubKeyFromHex(tag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPubkeys[pk]; ok {
|
||||
continue
|
||||
}
|
||||
seenPubkeys[pk] = struct{}{}
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchInboxRelays(ctx, pk, 15)...)
|
||||
}
|
||||
log("failed: %s\n", err)
|
||||
}
|
||||
if len(successRelays) > 0 && c.Bool("nevent") {
|
||||
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
|
||||
log(nevent + "\n")
|
||||
|
||||
if len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, c, relayUrls)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// print event as json
|
||||
if jq == nil {
|
||||
var result string
|
||||
if c.Bool("envelope") {
|
||||
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
||||
result = string(j)
|
||||
} else {
|
||||
j, _ := easyjson.Marshal(&evt)
|
||||
result = string(j)
|
||||
}
|
||||
stdout(result)
|
||||
} else {
|
||||
out, matches, err := jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if matches {
|
||||
stdout(out)
|
||||
}
|
||||
}
|
||||
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
}
|
||||
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
if err := handleEvent(stdinEvent); err != nil {
|
||||
ctx = lineProcessingError(ctx, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr.Event, relays []*nostr.Relay) error {
|
||||
doAuth := c.Bool("auth")
|
||||
|
||||
// publish to relays
|
||||
successRelays := make([]string, 0, len(relays))
|
||||
if len(relays) > 0 {
|
||||
os.Stdout.Sync()
|
||||
|
||||
if c.Bool("confirm") {
|
||||
relaysStr := make([]string, len(relays))
|
||||
for i, r := range relays {
|
||||
_, after, _ := strings.Cut(r.URL, "://")
|
||||
relaysStr[i] = strings.ToLower(after)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if supportsDynamicMultilineMagic() {
|
||||
// overcomplicated multiline rendering magic
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
urls := make([]string, len(relays))
|
||||
lines := make([][][]byte, len(urls))
|
||||
flush := func() {
|
||||
for _, line := range lines {
|
||||
for _, part := range line {
|
||||
os.Stderr.Write(part)
|
||||
}
|
||||
os.Stderr.Write([]byte{'\n'})
|
||||
}
|
||||
}
|
||||
render := func() {
|
||||
clearLines(len(lines))
|
||||
flush()
|
||||
}
|
||||
flush()
|
||||
|
||||
logthis := func(relayUrl, s string, args ...any) {
|
||||
idx := slices.Index(urls, relayUrl)
|
||||
lines[idx] = append(lines[idx], []byte(fmt.Sprintf(s, args...)))
|
||||
render()
|
||||
}
|
||||
colorizethis := func(relayUrl string, colorize func(string, ...any) string) {
|
||||
cleanUrl, _ := strings.CutPrefix(relayUrl, "wss://")
|
||||
idx := slices.Index(urls, relayUrl)
|
||||
lines[idx][0] = []byte(fmt.Sprintf("publishing to %s... ", colorize(cleanUrl)))
|
||||
render()
|
||||
}
|
||||
|
||||
for i, relay := range relays {
|
||||
urls[i] = relay.URL
|
||||
lines[i] = make([][]byte, 1, 3)
|
||||
colorizethis(relay.URL, color.CyanString)
|
||||
}
|
||||
render()
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, urls, evt) {
|
||||
if res.Error == nil {
|
||||
colorizethis(res.RelayURL, colors.successf)
|
||||
logthis(res.RelayURL, "success.")
|
||||
successRelays = append(successRelays, res.RelayURL)
|
||||
} else {
|
||||
colorizethis(res.RelayURL, colors.errorf)
|
||||
|
||||
// in this case it's likely that the lowest-level error is the one that will be more helpful
|
||||
low := unwrapAll(res.Error)
|
||||
|
||||
// hack for some messages such as from relay.westernbtc.com
|
||||
msg := strings.ReplaceAll(low.Error(), evt.PubKey.Hex(), "author")
|
||||
|
||||
// do not allow the message to overflow the term window
|
||||
msg = clampMessage(msg, 20+len(res.RelayURL))
|
||||
|
||||
logthis(res.RelayURL, msg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// normal dumb flow
|
||||
for i, relay := range relays {
|
||||
publish:
|
||||
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
|
||||
log("publishing to %s... ", color.CyanString(cleanUrl))
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !relay.IsConnected() {
|
||||
nm := nostr.NormalizeURL(relay.URL)
|
||||
if r, ok := sys.Pool.Relays.Load(nm); ok && r != nil && r.IsConnected() {
|
||||
relays[i] = r
|
||||
relay = r
|
||||
} else {
|
||||
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
new_, err := nostr.RelayConnect(ct, relay.URL, sys.Pool.RelayOptions)
|
||||
cancel()
|
||||
if err == nil {
|
||||
sys.Pool.Relays.Store(nm, new_)
|
||||
relays[i] = new_
|
||||
relay = new_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := relay.Publish(ctx, evt)
|
||||
if err == nil {
|
||||
// published fine
|
||||
log("success.\n")
|
||||
successRelays = append(successRelays, relay.URL)
|
||||
continue // continue to next relay
|
||||
}
|
||||
|
||||
// error publishing
|
||||
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
|
||||
// if the relay is requesting auth and we can auth, let's do it
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
|
||||
if err := relay.Auth(ctx, kr.SignEvent); err == nil {
|
||||
// try to publish again, but this time don't try to auth again
|
||||
doAuth = false
|
||||
goto publish
|
||||
} else {
|
||||
log("auth error: %s. ", err)
|
||||
}
|
||||
}
|
||||
log("failed: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(successRelays) > 0 && c.Bool("nevent") {
|
||||
log(nip19.EncodeNevent(evt.ID, successRelays, evt.PubKey) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
138
example_test.go
138
example_test.go
@@ -1,138 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
|
||||
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
|
||||
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func ExampleEventBasic() {
|
||||
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669"})
|
||||
// Output:
|
||||
// {"kind":1,"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
|
||||
}
|
||||
|
||||
// (for some reason there can only be one test dealing with stdin in the suite otherwise it halts)
|
||||
// func ExampleEventParsingFromStdin() {
|
||||
// prevStdin := os.Stdin
|
||||
// defer func() { os.Stdin = prevStdin }()
|
||||
// r, w, _ := os.Pipe()
|
||||
// os.Stdin = r
|
||||
// w.WriteString("{\"content\":\"hello world\"}\n{\"content\":\"hello sun\"}\n")
|
||||
// app.Run(ctx, []string{"nak", "event", "-t", "t=spam", "--ts", "1699485669"})
|
||||
// // Output:
|
||||
// // {"id":"bda134f9077c11973afe6aa5a1cc6f5bcea01c40d318b8f91dcb8e50507cfa52","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello world","sig":"7552454bb8e7944230142634e3e34ac7468bad9b21ed6909da572c611018dff1d14d0792e98b5806f6330edc51e09efa6d0b66a9694dc34606c70f4e580e7493"}
|
||||
// // {"id":"879c36ec73acca288825b53585389581d3836e7f0fe4d46e5eba237ca56d6af5","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello sun","sig":"6c7e6b13ebdf931d26acfdd00bec2ec1140ddaf8d1ed61453543a14e729a460fe36c40c488ccb194a0e1ab9511cb6c36741485f501bdb93c39ca4c51bc59cbd4"}
|
||||
// }
|
||||
|
||||
func ExampleEventComplex() {
|
||||
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def?name=foobar;nothing"})
|
||||
// Output:
|
||||
// {"kind":11,"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
|
||||
}
|
||||
|
||||
func ExampleEncode() {
|
||||
app.Run(ctx, []string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
||||
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
|
||||
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822", "a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"})
|
||||
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
|
||||
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
||||
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
|
||||
// nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a
|
||||
}
|
||||
|
||||
func ExampleDecode() {
|
||||
app.Run(ctx, []string{"nak", "decode", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
|
||||
// Output:
|
||||
// {
|
||||
// "pubkey": "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e",
|
||||
// "kind": 31923,
|
||||
// "identifier": "4cd6cfe7",
|
||||
// "relays": [
|
||||
// "wss://nos.lol"
|
||||
// ]
|
||||
// }
|
||||
// {
|
||||
// "id": "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5",
|
||||
// "relays": [
|
||||
// "wss://pyramid.fiatjaf.com/",
|
||||
// "wss://relay.westernbtc.com/",
|
||||
// "wss://relay.snort.social/",
|
||||
// "wss://atlas.nostr.land/"
|
||||
// ]
|
||||
// }
|
||||
}
|
||||
|
||||
func 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() {
|
||||
app.Run(ctx, []string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
|
||||
// Output:
|
||||
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
|
||||
}
|
||||
|
||||
func ExampleMultipleFetch() {
|
||||
app.Run(ctx, []string{"nak", "fetch", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
|
||||
// Output:
|
||||
// {"kind":31923,"id":"9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05","pubkey":"5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","created_at":1707764605,"tags":[["d","4cd6cfe7"],["name","Nostr PHX Presents Culture Shock"],["description","Nostr PHX presents Culture Shock the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, food, drinks, conversation, vendors and best of all, a live convert which will stream globally for the world to zap. "],["start","1708185600"],["end","1708228800"],["start_tzid","America/Phoenix"],["p","5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","","host"],["location","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["address","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["g","9tbq1rzn"],["image","https://flockstr.s3.amazonaws.com/event/15vSaiscDhVH1KBXhA0i8"],["about","Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. "],["calendar","31924:5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e:1f238c94"]],"content":"Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. ","sig":"f676629d1414d96b464644de6babde0c96bd21ef9b41ba69ad886a1d13a942b855b715b22ccf38bc07fead18d3bdeee82d9e3825cf6f003fb5ff1766d95c70a0"}
|
||||
// {"kind":1,"id":"3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1710759386,"tags":[],"content":"Nostr was coopted by our the corporate overlords. It is now featured in https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml.","sig":"faaec167cca4de50b562b7702e8854e2023f0ccd5f36d1b95b6eac20d352206342d6987e9516d283068c768e94dbe8858e2990c3e05405e707fb6fb771ef92f9"}
|
||||
}
|
||||
|
||||
func ExampleKeyPublic() {
|
||||
app.Run(ctx, []string{"nak", "key", "public", "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"})
|
||||
// Output:
|
||||
// 70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8
|
||||
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
|
||||
}
|
||||
|
||||
func ExampleKeyDecrypt() {
|
||||
app.Run(ctx, []string{"nak", "key", "decrypt", "ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft", "banana"})
|
||||
// Output:
|
||||
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
|
||||
}
|
||||
|
||||
func ExampleReqIdFromRelay() {
|
||||
app.Run(ctx, []string{"nak", "req", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1", "nos.lol"})
|
||||
// Output:
|
||||
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
|
||||
}
|
||||
|
||||
func ExampleReqWithFlagsAfter1() {
|
||||
app.Run(ctx, []string{"nak", "req", "nos.lol", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1"})
|
||||
// Output:
|
||||
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
|
||||
}
|
||||
|
||||
func ExampleReqWithFlagsAfter2() {
|
||||
app.Run(ctx, []string{"nak", "req", "-e", "893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1", "nostr.mom", "--author", "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", "--limit", "1", "-k", "7"})
|
||||
// Output:
|
||||
// {"kind":7,"id":"9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857","pubkey":"2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6","created_at":1720987327,"tags":[["e","893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1"],["p","1e978baae414eee990dba992871549ad4a099b9d6f7e71c8059b254ea024dddc"],["k","1"]],"content":"❤️","sig":"7eddd112c642ecdb031330dadc021790642b3c10ecc64158ba3ae63edd798b26afb9b5a3bba72835ce171719a724de1472f65c9b3339b6bead0ce2846f93dfc9"}
|
||||
}
|
||||
|
||||
func ExampleReqWithFlagsAfter3() {
|
||||
app.Run(ctx, []string{"nak", "req", "--limit", "1", "pyramid.fiatjaf.com", "-a", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-qp", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-e", "9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188"})
|
||||
// Output:
|
||||
// {"kind":1,"id":"101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67","pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","created_at":1720987305,"tags":[["e","ceacdc29fa7a0b51640b30d2424e188215460617db5ba5bb52d3fbf0094eebb3","","root"],["e","9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188","","reply"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","6b96c3eb36c6cd457d906bbaafe7b36cacfb8bcc4ab235be6eab3b71c6669251"]],"content":"Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.","sig":"b098820b4a5635865cada9f9a5813be2bc6dd7180e16e590cf30e07916d8ed6ed98ab38b64f3bfba12d88d37335f229f7ef8c084bc48132e936c664a54d3e650"}
|
||||
}
|
||||
|
||||
func ExampleNaturalTimestamps() {
|
||||
app.Run(ctx, []string{"nak", "event", "-t", "plu=pla", "-e", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "--ts", "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"}
|
||||
}
|
||||
81
fetch.go
81
fetch.go
@@ -4,38 +4,45 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip05"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk/hints"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var fetch = &cli.Command{
|
||||
Name: "fetch",
|
||||
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's NIP-65 relays.",
|
||||
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's outbox relays.",
|
||||
Description: `example usage:
|
||||
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
|
||||
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(reqFilterFlags,
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[nip05_or_nip19_code]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
defer func() {
|
||||
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
|
||||
relay.Close()
|
||||
return true
|
||||
})
|
||||
}()
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for code := range getStdinLinesOrArguments(c.Args()) {
|
||||
filter := nostr.Filter{}
|
||||
var authorHint string
|
||||
var authorHint nostr.PubKey
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if nip05.IsValidIdentifier(code) {
|
||||
@@ -62,15 +69,15 @@ var fetch = &cli.Command{
|
||||
case "nevent":
|
||||
v := value.(nostr.EventPointer)
|
||||
filter.IDs = append(filter.IDs, v.ID)
|
||||
if v.Author != "" {
|
||||
if v.Author != nostr.ZeroPK {
|
||||
authorHint = v.Author
|
||||
}
|
||||
relays = append(relays, v.Relays...)
|
||||
case "note":
|
||||
filter.IDs = append(filter.IDs, value.(string))
|
||||
filter.IDs = append(filter.IDs, value.(nostr.EventPointer).ID)
|
||||
case "naddr":
|
||||
v := value.(nostr.EntityPointer)
|
||||
filter.Kinds = []int{v.Kind}
|
||||
filter.Kinds = []nostr.Kind{v.Kind}
|
||||
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
|
||||
filter.Authors = append(filter.Authors, v.PublicKey)
|
||||
authorHint = v.PublicKey
|
||||
@@ -81,7 +88,7 @@ var fetch = &cli.Command{
|
||||
authorHint = v.PublicKey
|
||||
relays = append(relays, v.Relays...)
|
||||
case "npub":
|
||||
v := value.(string)
|
||||
v := value.(nostr.PubKey)
|
||||
filter.Authors = append(filter.Authors, v)
|
||||
authorHint = v
|
||||
default:
|
||||
@@ -89,28 +96,52 @@ var fetch = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
if authorHint != "" {
|
||||
relays := sys.FetchOutboxRelays(ctx, authorHint, 3)
|
||||
if authorHint != nostr.ZeroPK {
|
||||
for _, url := range relays {
|
||||
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
|
||||
}
|
||||
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 6) {
|
||||
relays = append(relays, url)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filter.Authors) > 0 && len(filter.Kinds) == 0 {
|
||||
filter.Kinds = append(filter.Kinds, 0)
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx = lineProcessingError(ctx, "no relay hints found")
|
||||
continue
|
||||
}
|
||||
|
||||
for ie := range sys.Pool.SubManyEose(ctx, relays, nostr.Filters{filter}) {
|
||||
stdout(ie.Event)
|
||||
found := false
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-fetch",
|
||||
}) {
|
||||
found = true
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
v, matches, err := jq(ie.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
}
|
||||
|
||||
if !found {
|
||||
ctx = lineProcessingError(ctx, "no events found for %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
125
filter.go
Normal file
125
filter.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var filterCmd = &cli.Command{
|
||||
Name: "filter",
|
||||
Usage: "applies an event filter to an event to see if it matches.",
|
||||
Description: `
|
||||
example:
|
||||
echo '{"kind": 1, "content": "hello"}' | nak filter -k 1
|
||||
nak filter '{"kind": 1, "content": "hello"}' -k 1
|
||||
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
|
||||
`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter matching events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[event_json] [base_filter_json]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
|
||||
var baseFilter nostr.Filter
|
||||
var baseEvent nostr.Event
|
||||
|
||||
if len(args) == 2 {
|
||||
// two arguments: first is event, second is base filter
|
||||
if err := easyjson.Unmarshal([]byte(args[0]), &baseEvent); err != nil {
|
||||
return fmt.Errorf("invalid base event: %w", err)
|
||||
}
|
||||
if err := easyjson.Unmarshal([]byte(args[1]), &baseFilter); err != nil {
|
||||
return fmt.Errorf("invalid base filter: %w", err)
|
||||
}
|
||||
} else if len(args) == 1 {
|
||||
if isPiped() {
|
||||
// one argument + stdin: argument is base filter
|
||||
if err := easyjson.Unmarshal([]byte(args[0]), &baseFilter); err != nil {
|
||||
return fmt.Errorf("invalid base filter: %w", err)
|
||||
}
|
||||
} else {
|
||||
// one argument, no stdin: argument is event
|
||||
if err := easyjson.Unmarshal([]byte(args[0]), &baseEvent); err != nil {
|
||||
return fmt.Errorf("invalid base event: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply flags to filter
|
||||
if err := applyFlagsToFilter(c, &baseFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if there is no stdin we'll still get an empty object here
|
||||
for evtj := range getJsonsOrBlank() {
|
||||
var evt nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(evtj), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// merge that with the base event
|
||||
if evt.ID == nostr.ZeroID {
|
||||
evt.ID = baseEvent.ID
|
||||
}
|
||||
if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey = baseEvent.PubKey
|
||||
}
|
||||
if evt.Sig == [64]byte{} {
|
||||
evt.Sig = baseEvent.Sig
|
||||
}
|
||||
if evt.Kind == 0 {
|
||||
evt.Kind = baseEvent.Kind
|
||||
}
|
||||
if evt.Content == "" {
|
||||
evt.Content = baseEvent.Content
|
||||
}
|
||||
if len(evt.Tags) == 0 {
|
||||
evt.Tags = baseEvent.Tags
|
||||
}
|
||||
if evt.CreatedAt == 0 {
|
||||
evt.CreatedAt = baseEvent.CreatedAt
|
||||
}
|
||||
|
||||
if baseFilter.Matches(evt) {
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
} else {
|
||||
logverbose("event %s didn't match %s", evt, baseFilter)
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
345
flags.go
345
flags.go
@@ -6,14 +6,69 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/markusmobius/go-dateparser"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type (
|
||||
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
|
||||
)
|
||||
|
||||
type boolIntValue struct {
|
||||
int int
|
||||
defaultWhenSet int
|
||||
hasDefault bool
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
|
||||
|
||||
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
|
||||
*p = val
|
||||
|
||||
return &boolIntValue{
|
||||
defaultWhenSet: val,
|
||||
hasDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t boolIntValue) IsBoolFlag() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t boolIntValue) ToString(b int) string { return "<<>>" }
|
||||
|
||||
func (t *boolIntValue) Set(value string) error {
|
||||
t.hasBeenSet = true
|
||||
if value == "true" {
|
||||
if t.hasDefault {
|
||||
t.int = t.defaultWhenSet
|
||||
} else {
|
||||
t.int = 1
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
var err error
|
||||
t.int, err = strconv.Atoi(value)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
|
||||
func (t *boolIntValue) Value() int { return t.int }
|
||||
func (t *boolIntValue) Get() any { return t.int }
|
||||
|
||||
func getBoolInt(cmd *cli.Command, name string) int {
|
||||
return cmd.Value(name).(int)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||
|
||||
// wrap to satisfy golang's flag interface.
|
||||
type naturalTimeValue struct {
|
||||
timestamp *nostr.Timestamp
|
||||
hasBeenSet bool
|
||||
@@ -21,8 +76,6 @@ type naturalTimeValue struct {
|
||||
|
||||
var _ cli.ValueCreator[nostr.Timestamp, struct{}] = naturalTimeValue{}
|
||||
|
||||
// Below functions are to satisfy the ValueCreator interface
|
||||
|
||||
func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &naturalTimeValue{
|
||||
@@ -32,21 +85,12 @@ func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c stru
|
||||
|
||||
func (t naturalTimeValue) ToString(b nostr.Timestamp) string {
|
||||
ts := b.Time()
|
||||
|
||||
if ts.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", ts)
|
||||
}
|
||||
|
||||
// Timestamp constructor(for internal testing only)
|
||||
func newTimestamp(timestamp nostr.Timestamp) *naturalTimeValue {
|
||||
return &naturalTimeValue{timestamp: ×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 {
|
||||
@@ -61,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, value)
|
||||
ts = date.Time
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts = date.Time
|
||||
}
|
||||
|
||||
if t.timestamp != nil {
|
||||
@@ -75,21 +119,264 @@ func (t *naturalTimeValue) Set(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a readable representation of this value (for usage defaults)
|
||||
func (t *naturalTimeValue) String() string {
|
||||
return fmt.Sprintf("%#v", t.timestamp)
|
||||
}
|
||||
|
||||
// Value returns the timestamp value stored in the flag
|
||||
func (t *naturalTimeValue) Value() *nostr.Timestamp {
|
||||
return t.timestamp
|
||||
}
|
||||
|
||||
// Get returns the flag structure
|
||||
func (t *naturalTimeValue) Get() any {
|
||||
return *t.timestamp
|
||||
}
|
||||
func (t *naturalTimeValue) String() string { return fmt.Sprintf("%#v", t.timestamp) }
|
||||
func (t *naturalTimeValue) Value() *nostr.Timestamp { return t.timestamp }
|
||||
func (t *naturalTimeValue) Get() any { return *t.timestamp }
|
||||
|
||||
func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
|
||||
return cmd.Value(name).(nostr.Timestamp)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
SecretKeyFlag = cli.FlagBase[nostr.SecretKey, struct{}, secretkeyValue]
|
||||
)
|
||||
|
||||
type secretkeyValue struct {
|
||||
secretkey nostr.SecretKey
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.SecretKey, struct{}] = secretkeyValue{}
|
||||
|
||||
func (t secretkeyValue) Create(val nostr.SecretKey, p *nostr.SecretKey, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &secretkeyValue{
|
||||
secretkey: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t secretkeyValue) ToString(b nostr.SecretKey) string { return t.secretkey.String() }
|
||||
|
||||
func (t *secretkeyValue) Set(value string) error {
|
||||
secretkey, err := parseSecretKey(value)
|
||||
t.secretkey = secretkey
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *secretkeyValue) String() string { return fmt.Sprintf("%#v", t.secretkey) }
|
||||
func (t *secretkeyValue) Value() nostr.SecretKey { return t.secretkey }
|
||||
func (t *secretkeyValue) Get() any { return t.secretkey }
|
||||
|
||||
func getSecretKey(cmd *cli.Command, name string) nostr.SecretKey {
|
||||
return cmd.Value(name).(nostr.SecretKey)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
PubKeyFlag = cli.FlagBase[nostr.PubKey, struct{}, pubkeyValue]
|
||||
)
|
||||
|
||||
type pubkeyValue struct {
|
||||
pubkey nostr.PubKey
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.PubKey, struct{}] = pubkeyValue{}
|
||||
|
||||
func (t pubkeyValue) Create(val nostr.PubKey, p *nostr.PubKey, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &pubkeyValue{
|
||||
pubkey: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t pubkeyValue) ToString(b nostr.PubKey) string { return t.pubkey.String() }
|
||||
|
||||
func (t *pubkeyValue) Set(value string) error {
|
||||
pubkey, err := parsePubKey(value)
|
||||
t.pubkey = pubkey
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *pubkeyValue) String() string { return fmt.Sprintf("%#v", t.pubkey) }
|
||||
func (t *pubkeyValue) Value() nostr.PubKey { return t.pubkey }
|
||||
func (t *pubkeyValue) Get() any { return t.pubkey }
|
||||
|
||||
func getPubKey(cmd *cli.Command, name string) nostr.PubKey {
|
||||
return cmd.Value(name).(nostr.PubKey)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
pubkeySlice = cli.SliceBase[nostr.PubKey, struct{}, pubkeyValue]
|
||||
PubKeySliceFlag = cli.FlagBase[[]nostr.PubKey, struct{}, pubkeySlice]
|
||||
)
|
||||
|
||||
func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
|
||||
return cmd.Value(name).([]nostr.PubKey)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type PubKeyOrAddress struct {
|
||||
PubKey nostr.PubKey
|
||||
Addr *nostr.EntityPointer
|
||||
}
|
||||
|
||||
type (
|
||||
pubKeyOrAddressValue struct {
|
||||
value PubKeyOrAddress
|
||||
hasBeenSet bool
|
||||
}
|
||||
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
|
||||
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
|
||||
)
|
||||
|
||||
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
|
||||
|
||||
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &pubKeyOrAddressValue{
|
||||
value: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
|
||||
if b.Addr != nil {
|
||||
return b.Addr.AsTagReference()
|
||||
}
|
||||
return b.PubKey.String()
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) Set(value string) error {
|
||||
pubkey, err1 := parsePubKey(value)
|
||||
if err1 == nil {
|
||||
t.value = PubKeyOrAddress{PubKey: pubkey}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
addr, err2 := nostr.ParseAddrString(value)
|
||||
if err2 == nil {
|
||||
t.value = PubKeyOrAddress{Addr: &addr}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
|
||||
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
|
||||
func (t *pubKeyOrAddressValue) Get() any { return t.value }
|
||||
|
||||
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
|
||||
return cmd.Value(name).([]PubKeyOrAddress)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
|
||||
)
|
||||
|
||||
type idValue struct {
|
||||
id nostr.ID
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.ID, struct{}] = idValue{}
|
||||
|
||||
func (t idValue) Create(val nostr.ID, p *nostr.ID, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &idValue{
|
||||
id: val,
|
||||
}
|
||||
}
|
||||
func (t idValue) ToString(b nostr.ID) string { return t.id.String() }
|
||||
|
||||
func (t *idValue) Set(value string) error {
|
||||
id, err := parseEventID(value)
|
||||
t.id = id
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *idValue) String() string { return fmt.Sprintf("%#v", t.id) }
|
||||
func (t *idValue) Value() nostr.ID { return t.id }
|
||||
func (t *idValue) Get() any { return t.id }
|
||||
|
||||
func getID(cmd *cli.Command, name string) nostr.ID {
|
||||
return cmd.Value(name).(nostr.ID)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
idSlice = cli.SliceBase[nostr.ID, struct{}, idValue]
|
||||
IDSliceFlag = cli.FlagBase[[]nostr.ID, struct{}, idSlice]
|
||||
)
|
||||
|
||||
func getIDSlice(cmd *cli.Command, name string) []nostr.ID {
|
||||
return cmd.Value(name).([]nostr.ID)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
KindFlag = cli.FlagBase[nostr.Kind, struct{}, kindValue]
|
||||
)
|
||||
|
||||
type kindValue struct {
|
||||
kind nostr.Kind
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.Kind, struct{}] = kindValue{}
|
||||
|
||||
func (t kindValue) Create(val nostr.Kind, p *nostr.Kind, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &kindValue{
|
||||
kind: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t kindValue) ToString(b nostr.Kind) string { return fmt.Sprintf("%d", b) }
|
||||
|
||||
func (t *kindValue) Set(value string) error {
|
||||
k, err := stringToKind(value)
|
||||
t.kind = k
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *kindValue) String() string { return fmt.Sprintf("%#v", t.kind) }
|
||||
func (t *kindValue) Value() nostr.Kind { return t.kind }
|
||||
func (t *kindValue) Get() any { return t.kind }
|
||||
|
||||
func getKind(cmd *cli.Command, name string) nostr.Kind {
|
||||
return cmd.Value(name).(nostr.Kind)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
kindSlice = cli.SliceBase[nostr.Kind, struct{}, kindValue]
|
||||
KindSliceFlag = cli.FlagBase[[]nostr.Kind, struct{}, kindSlice]
|
||||
)
|
||||
|
||||
func getKindSlice(cmd *cli.Command, name string) []nostr.Kind {
|
||||
return cmd.Value(name).([]nostr.Kind)
|
||||
}
|
||||
|
||||
123
fs.go
Normal file
123
fs.go
Normal file
@@ -0,0 +1,123 @@
|
||||
//go:build !windows && !openbsd && !cgofuse
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/nak/nostrfs"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
timeout := time.Second * 120
|
||||
server, err := fs.Mount(mountpoint, root, &fs.Options{
|
||||
MountOptions: fuse.MountOptions{
|
||||
Debug: isVerbose,
|
||||
Name: "nak",
|
||||
FsName: "nak",
|
||||
RememberInodes: true,
|
||||
},
|
||||
AttrTimeout: &timeout,
|
||||
EntryTimeout: &timeout,
|
||||
Logger: nostr.DebugLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mount failed: %w", err)
|
||||
}
|
||||
log("ok.\n")
|
||||
|
||||
// setup signal handling for clean unmount
|
||||
ch := make(chan os.Signal, 1)
|
||||
chErr := make(chan error)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-ch
|
||||
log("- unmounting... ")
|
||||
err := server.Unmount()
|
||||
if err != nil {
|
||||
chErr <- fmt.Errorf("unmount failed: %w", err)
|
||||
} else {
|
||||
log("ok\n")
|
||||
chErr <- nil
|
||||
}
|
||||
}()
|
||||
|
||||
// serve the filesystem until unmounted
|
||||
server.Wait()
|
||||
return <-chErr
|
||||
},
|
||||
}
|
||||
118
fs_cgo.go
Normal file
118
fs_cgo.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build cgofuse && !windows && !openbsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem
|
||||
mountArgs := []string{"-s", mountpoint}
|
||||
if isVerbose {
|
||||
mountArgs = append([]string{"-d"}, mountArgs...)
|
||||
}
|
||||
|
||||
go func() {
|
||||
host.Mount("", mountArgs)
|
||||
}()
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
// setup signal handling for clean unmount
|
||||
ch := make(chan os.Signal, 1)
|
||||
chErr := make(chan error)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-ch
|
||||
log("- unmounting... ")
|
||||
// cgofuse doesn't have explicit unmount, it unmounts on process exit
|
||||
log("ok\n")
|
||||
chErr <- nil
|
||||
}()
|
||||
|
||||
// wait for signals
|
||||
return <-chErr
|
||||
},
|
||||
}
|
||||
20
fs_other.go
Normal file
20
fs_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build openbsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `doesn't work on Windows and OpenBSD.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return fmt.Errorf("this doesn't work on OpenBSD.")
|
||||
},
|
||||
}
|
||||
140
fs_windows.go
Normal file
140
fs_windows.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem - Windows/WinFsp version
|
||||
// based on rclone cmount implementation
|
||||
mountArgs := []string{
|
||||
"-o", "uid=-1",
|
||||
"-o", "gid=-1",
|
||||
"--FileSystemName=nak",
|
||||
}
|
||||
|
||||
// check if mountpoint is a drive letter or directory
|
||||
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
|
||||
|
||||
if !isDriveLetter {
|
||||
// winFsp primarily supports drive letters on Windows
|
||||
// directory mounting may not work reliably
|
||||
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
|
||||
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
|
||||
|
||||
// for directory mounts, follow rclone's approach:
|
||||
// 1. check that mountpoint doesn't already exist
|
||||
if _, err := os.Stat(mountpoint); err == nil {
|
||||
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to check mountpoint: %w", err)
|
||||
}
|
||||
|
||||
// 2. check that parent directory exists
|
||||
parent := filepath.Join(mountpoint, "..")
|
||||
if _, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
|
||||
}
|
||||
return fmt.Errorf("failed to check parent directory: %w", err)
|
||||
}
|
||||
|
||||
// 3. use network mode for directory mounts
|
||||
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
|
||||
}
|
||||
|
||||
if isVerbose {
|
||||
mountArgs = append(mountArgs, "-o", "debug")
|
||||
}
|
||||
mountArgs = append(mountArgs, mountpoint)
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
if !host.Mount("", mountArgs) {
|
||||
return fmt.Errorf("failed to mount filesystem")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
371
gift.go
Normal file
371
gift.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var gift = &cli.Command{
|
||||
Name: "gift",
|
||||
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
||||
Description: `example:
|
||||
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>
|
||||
|
||||
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "wrap",
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "use-our-identity-key",
|
||||
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "use-their-identity-key",
|
||||
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
|
||||
},
|
||||
},
|
||||
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
|
||||
Description: `example:
|
||||
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get sender pubkey (ourselves)
|
||||
sender, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
||||
}
|
||||
|
||||
var using bool
|
||||
|
||||
var cipher nostr.Cipher = kr
|
||||
// use decoupled key if it exists
|
||||
using = false
|
||||
if !c.Bool("use-our-identity-key") {
|
||||
configPath := c.String("config-path")
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
|
||||
}
|
||||
cipher = keyer.NewPlainKeySigner(eSec)
|
||||
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
using = true
|
||||
}
|
||||
}
|
||||
if !using {
|
||||
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
|
||||
}
|
||||
|
||||
recipient := getPubKey(c, "recipient-pubkey")
|
||||
using = false
|
||||
if !c.Bool("use-their-identity-key") {
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
|
||||
recipient = theirEPub
|
||||
using = true
|
||||
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
|
||||
}
|
||||
}
|
||||
if !using {
|
||||
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
|
||||
}
|
||||
|
||||
// read event from stdin
|
||||
for eventJSON := range getJsonsOrBlank() {
|
||||
if eventJSON == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
var originalEvent nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(eventJSON), &originalEvent); err != nil {
|
||||
return fmt.Errorf("invalid event JSON: %w", err)
|
||||
}
|
||||
|
||||
// turn into rumor (unsigned event)
|
||||
rumor := originalEvent
|
||||
rumor.Sig = [64]byte{} // remove signature
|
||||
rumor.PubKey = sender
|
||||
rumor.ID = rumor.GetID() // compute ID
|
||||
|
||||
// create seal
|
||||
rumorJSON, _ := easyjson.Marshal(rumor)
|
||||
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
||||
}
|
||||
seal := &nostr.Event{
|
||||
Kind: 13,
|
||||
Content: encryptedRumor,
|
||||
PubKey: sender,
|
||||
CreatedAt: randomNow(),
|
||||
Tags: nostr.Tags{},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, seal); err != nil {
|
||||
return fmt.Errorf("failed to sign seal: %w", err)
|
||||
}
|
||||
|
||||
// create gift wrap
|
||||
ephemeral := nostr.Generate()
|
||||
sealJSON, _ := easyjson.Marshal(seal)
|
||||
convkey, err := nip44.GenerateConversationKey(recipient, ephemeral)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate conversation key: %w", err)
|
||||
}
|
||||
encryptedSeal, err := nip44.Encrypt(string(sealJSON), convkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt seal: %w", err)
|
||||
}
|
||||
wrap := &nostr.Event{
|
||||
Kind: 1059,
|
||||
Content: encryptedSeal,
|
||||
CreatedAt: randomNow(),
|
||||
Tags: nostr.Tags{{"p", recipient.Hex()}},
|
||||
}
|
||||
wrap.Sign(ephemeral)
|
||||
|
||||
// print the gift-wrap
|
||||
wrapJSON, err := easyjson.Marshal(wrap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal gift wrap: %w", err)
|
||||
}
|
||||
stdout(string(wrapJSON))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unwrap",
|
||||
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
||||
Description: `example:
|
||||
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get receiver public key (ourselves)
|
||||
receiver, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ciphers := []nostr.Cipher{kr}
|
||||
// use decoupled key if it exists
|
||||
configPath := c.String("config-path")
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
}
|
||||
ciphers = append(ciphers, kr)
|
||||
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||
}
|
||||
|
||||
// read gift-wrapped event from stdin
|
||||
for wrapJSON := range getJsonsOrBlank() {
|
||||
if wrapJSON == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
var wrap nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(wrapJSON), &wrap); err != nil {
|
||||
return fmt.Errorf("invalid gift wrap JSON: %w", err)
|
||||
}
|
||||
|
||||
if wrap.Kind != 1059 {
|
||||
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
||||
}
|
||||
|
||||
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
|
||||
var cipher nostr.Cipher
|
||||
var seal nostr.Event
|
||||
|
||||
// try both the receiver identity key and decoupled key
|
||||
err = nil
|
||||
for c, potentialCipher := range ciphers {
|
||||
switch c {
|
||||
case 0:
|
||||
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
case 1:
|
||||
if eSec.Public() == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
}
|
||||
|
||||
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||
if thisErr != nil {
|
||||
err = thisErr
|
||||
continue
|
||||
}
|
||||
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
|
||||
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
|
||||
cipher = potentialCipher
|
||||
break
|
||||
}
|
||||
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
|
||||
// if both ciphers failed above we'll reach here
|
||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||
}
|
||||
|
||||
if seal.Kind != 13 {
|
||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||
}
|
||||
|
||||
if !seal.VerifySignature() {
|
||||
return fmt.Errorf("seal signature is invalid")
|
||||
}
|
||||
|
||||
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
|
||||
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
|
||||
}
|
||||
|
||||
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
|
||||
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
|
||||
var rumor nostr.Event
|
||||
err = nil
|
||||
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
|
||||
if senderEncryptionPublicKey == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
|
||||
switch s {
|
||||
case 0:
|
||||
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
case 1:
|
||||
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
}
|
||||
|
||||
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
|
||||
if thisErr != nil {
|
||||
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
|
||||
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
|
||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
||||
}
|
||||
|
||||
rumor.PubKey = seal.PubKey
|
||||
rumor.ID = rumor.GetID()
|
||||
|
||||
// output the unwrapped event (rumor)
|
||||
stdout(rumor.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func randomNow() nostr.Timestamp {
|
||||
const twoDays = 2 * 24 * 60 * 60
|
||||
now := time.Now().Unix()
|
||||
randomOffset := rand.Int63n(twoDays)
|
||||
return nostr.Timestamp(now - randomOffset)
|
||||
}
|
||||
|
||||
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
|
||||
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||
if ok {
|
||||
var ePub nostr.PubKey
|
||||
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||
}
|
||||
|
||||
// check if we have the key
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
|
||||
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||
eSec, err := nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
|
||||
}
|
||||
return eSec, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return [32]byte{}, false, nil
|
||||
}
|
||||
|
||||
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
|
||||
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||
if ok {
|
||||
var ePub nostr.PubKey
|
||||
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return nostr.ZeroPK, false
|
||||
}
|
||||
|
||||
return ePub, true
|
||||
}
|
||||
|
||||
return nostr.ZeroPK, false
|
||||
}
|
||||
110
go.mod
110
go.mod
@@ -1,61 +1,115 @@
|
||||
module github.com/fiatjaf/nak
|
||||
|
||||
go 1.23.1
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae
|
||||
github.com/fiatjaf/eventstore v0.14.2
|
||||
github.com/fiatjaf/khatru v0.14.0
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/mailru/easyjson v0.7.7
|
||||
github.com/liamg/magic v0.0.1
|
||||
github.com/mailru/easyjson v0.9.1
|
||||
github.com/mark3labs/mcp-go v0.8.3
|
||||
github.com/markusmobius/go-dateparser v1.2.3
|
||||
github.com/nbd-wtf/go-nostr v0.47.0
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||
github.com/winfsp/cgofuse v1.6.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
|
||||
fiatjaf.com/lib v0.3.7
|
||||
github.com/denisbrodbeck/machineid v1.0.1
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
github.com/itchyny/gojq v0.12.19
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/mattn/go-tty/v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
github.com/PowerDNS/lmdb-go v1.9.3 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bluekeyes/go-gitdiff v0.7.1 // indirect
|
||||
github.com/btcsuite/btcd v0.24.2 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chzyer/logex v1.1.10 // indirect
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/pie/v2 v2.7.0 // indirect
|
||||
github.com/fasthttp/websocket v1.5.7 // indirect
|
||||
github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect
|
||||
github.com/elnosh/gonuts v0.4.2 // indirect
|
||||
github.com/fasthttp/websocket v1.5.12 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hablullah/go-hijri v1.0.2 // indirect
|
||||
github.com/hablullah/go-juliandays v1.0.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.8 // indirect
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/templexxx/cpu v0.0.1 // indirect
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
|
||||
github.com/tetratelabs/wazero v1.8.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/fasthttp v1.59.0 // indirect
|
||||
github.com/wasilibs/go-re2 v1.3.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
278
go.sum
278
go.sum
@@ -1,21 +1,49 @@
|
||||
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
|
||||
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
|
||||
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e h1:/vgoytiH4qQ28O/oX1hj656X69CnTy1pGj/17HFjH+A=
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
||||
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
|
||||
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bluekeyes/go-gitdiff v0.7.1 h1:graP4ElLRshr8ecu0UtqfNTCHrtSyZd3DABQm/DWesQ=
|
||||
github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
@@ -29,16 +57,40 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -47,29 +99,34 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
|
||||
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/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
|
||||
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||
github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg=
|
||||
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
|
||||
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
|
||||
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
|
||||
github.com/elnosh/gonuts v0.4.2 h1:/WubPAWGxTE+okJ0WPvmtEzTzpi04RGxiTHAF1FYU+M=
|
||||
github.com/elnosh/gonuts v0.4.2/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
|
||||
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
|
||||
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae h1:0B/1dU3YECIbPoBIRTQ4c0scZCNz9TVHtQpiODGrTTo=
|
||||
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae/go.mod h1:aAWPO4bixZZxPtOnH6K3q4GbQ0jftUNDW9Oa861IRew=
|
||||
github.com/fiatjaf/eventstore v0.14.2 h1:1XOLLEBCGQNQ1rLaO8mcfTQydcetPOqb/uRK8zOnOSI=
|
||||
github.com/fiatjaf/eventstore v0.14.2/go.mod h1:XmYZSdFxsY+cwfpdzVG61M7pemcmqlZwDfDGFC8WwWo=
|
||||
github.com/fiatjaf/khatru v0.14.0 h1:zpWlAA87XBpDKBPIDbAuNw/HpKXzyt5XHVDbSvUbmDo=
|
||||
github.com/fiatjaf/khatru v0.14.0/go.mod h1:uxE5e8DBXPZqbHjr/gfatQas5bEJIMmsOCDcdF4LoRQ=
|
||||
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/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
|
||||
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
@@ -81,14 +138,29 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
|
||||
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
|
||||
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
||||
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
|
||||
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
|
||||
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
|
||||
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -98,27 +170,59 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
|
||||
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
|
||||
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
|
||||
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
|
||||
github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-tty/v2 v2.0.0 h1:AgXDfbKcENaiQbjdQ+o2ZusbtiaOhK1ArFD75d5U6qk=
|
||||
github.com/mattn/go-tty/v2 v2.0.0/go.mod h1:azVwsnH46TUJRQPRp/IrUT+8nRkkvjvkESJJxAA4Y48=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nbd-wtf/go-nostr v0.47.0 h1:TM3kf3arDoyVqTmfIbYkgYsKJtIWydMF0zvAbaTenk4=
|
||||
github.com/nbd-wtf/go-nostr v0.47.0/go.mod h1:O6n8bv+KktkEs+4svL7KN/OSnOWB5LzcZbuKjxnpRD0=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -132,52 +236,95 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY=
|
||||
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
|
||||
github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g=
|
||||
github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
|
||||
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
|
||||
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
|
||||
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -185,16 +332,33 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -204,6 +368,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -212,3 +378,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
733
helpers.go
733
helpers.go
@@ -3,103 +3,160 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/sdk"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var sys = sdk.NewSystem()
|
||||
var sys *sdk.System
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
func init() {
|
||||
sys.Pool = nostr.NewSimplePool(context.Background(),
|
||||
nostr.WithRelayOptions(
|
||||
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
LINE_PROCESSING_ERROR = iota
|
||||
)
|
||||
|
||||
var log = func(msg string, args ...any) {
|
||||
fmt.Fprintf(color.Error, msg, args...)
|
||||
}
|
||||
var (
|
||||
log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) }
|
||||
logverbose = func(msg string, args ...any) {} // by default do nothing
|
||||
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
|
||||
)
|
||||
|
||||
var stdout = fmt.Println
|
||||
var connectTimeout = 2 * time.Second
|
||||
|
||||
func isPiped() bool {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
return stat.Mode()&os.ModeCharDevice == 0
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mode := stat.Mode()
|
||||
is := mode&os.ModeCharDevice == 0
|
||||
return is
|
||||
}
|
||||
|
||||
func getStdinLinesOrBlank() chan string {
|
||||
multi := make(chan string)
|
||||
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
|
||||
single := make(chan string, 1)
|
||||
single <- ""
|
||||
close(single)
|
||||
return single
|
||||
} else {
|
||||
return multi
|
||||
func getJsonsOrBlank() iter.Seq[string] {
|
||||
var curr strings.Builder
|
||||
|
||||
var finalJsonErr error
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
// we're look for an event, but it may be in multiple lines, so if json parsing fails
|
||||
// we'll try the next line until we're successful
|
||||
curr.WriteString(stdinLine)
|
||||
stdinEvent := curr.String()
|
||||
|
||||
var dummy any
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
|
||||
finalJsonErr = err
|
||||
return true
|
||||
}
|
||||
finalJsonErr = nil
|
||||
|
||||
if !yield(stdinEvent) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
|
||||
curr.Reset()
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
if !yield("{}") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if finalJsonErr != nil {
|
||||
log(color.YellowString("stdin json parse error: %s", finalJsonErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getStdinLinesOrArguments(args cli.Args) chan string {
|
||||
func getStdinLinesOrBlank() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
if !yield(stdinLine) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
if !yield("") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
|
||||
return getStdinLinesOrArgumentsFromSlice(args.Slice())
|
||||
}
|
||||
|
||||
func getStdinLinesOrArgumentsFromSlice(args []string) chan string {
|
||||
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
|
||||
// try the first argument
|
||||
if len(args) > 0 {
|
||||
argsCh := make(chan string, 1)
|
||||
go func() {
|
||||
for _, arg := range args {
|
||||
argsCh <- arg
|
||||
}
|
||||
close(argsCh)
|
||||
}()
|
||||
return argsCh
|
||||
return slices.Values(args)
|
||||
}
|
||||
|
||||
// try the stdin
|
||||
multi := make(chan string)
|
||||
if !writeStdinLinesOrNothing(multi) {
|
||||
close(multi)
|
||||
return func(yield func(string) bool) {
|
||||
writeStdinLinesOrNothing(yield)
|
||||
}
|
||||
return multi
|
||||
}
|
||||
|
||||
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
|
||||
func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
|
||||
if isPiped() {
|
||||
// piped
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
|
||||
hasEmittedAtLeastOne := false
|
||||
for scanner.Scan() {
|
||||
ch <- strings.TrimSpace(scanner.Text())
|
||||
hasEmittedAtLeastOne = true
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
|
||||
hasEmittedAtLeastOne := false
|
||||
for scanner.Scan() {
|
||||
if !yield(strings.TrimSpace(scanner.Text())) {
|
||||
return
|
||||
}
|
||||
if !hasEmittedAtLeastOne {
|
||||
ch <- ""
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return true
|
||||
hasEmittedAtLeastOne = true
|
||||
}
|
||||
return hasEmittedAtLeastOne
|
||||
} else {
|
||||
// not piped
|
||||
return false
|
||||
@@ -130,67 +187,189 @@ func normalizeAndValidateRelayURLs(wsurls []string) error {
|
||||
|
||||
func connectToAllRelays(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
relayUrls []string,
|
||||
forcePreAuth bool,
|
||||
opts ...nostr.PoolOption,
|
||||
) []*nostr.Relay {
|
||||
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))
|
||||
relayLoop:
|
||||
for _, url := range relayUrls {
|
||||
log("connecting to %s... ", url)
|
||||
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
|
||||
if forcePreAuth {
|
||||
log("waiting for auth challenge... ")
|
||||
signer := opts[0].(nostr.WithAuthHandler)
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
challengeWaitLoop:
|
||||
for {
|
||||
// beginhack
|
||||
// here starts the biggest and ugliest hack of this codebase
|
||||
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
|
||||
challengeTag := authEvent.Tags.GetFirst([]string{"challenge", ""})
|
||||
if (*challengeTag)[1] == "" {
|
||||
return fmt.Errorf("auth not received yet *****")
|
||||
}
|
||||
return signer(ctx, nostr.RelayEvent{Event: authEvent, Relay: relay})
|
||||
}); err == nil {
|
||||
// auth succeeded
|
||||
break challengeWaitLoop
|
||||
} else {
|
||||
// auth failed
|
||||
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
|
||||
// it failed because we didn't receive the challenge yet, so keep waiting
|
||||
time.Sleep(time.Second)
|
||||
continue challengeWaitLoop
|
||||
} else {
|
||||
// it failed for some other reason, so skip this relay
|
||||
log(err.Error() + "\n")
|
||||
continue relayLoop
|
||||
}
|
||||
}
|
||||
// endhack
|
||||
}
|
||||
}
|
||||
|
||||
relays = append(relays, relay)
|
||||
log("ok.\n")
|
||||
} else {
|
||||
log(err.Error() + "\n")
|
||||
// first pass to check if these are valid relay URLs
|
||||
for i, url := range relayUrls {
|
||||
url = nostr.NormalizeURL(url)
|
||||
relayUrls[i] = url
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
log("invalid relay URL: %s\n", url)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
|
||||
relays := make([]*nostr.Relay, 0, len(relayUrls))
|
||||
|
||||
if supportsDynamicMultilineMagic() {
|
||||
// overcomplicated multiline rendering magic
|
||||
lines := make([][][]byte, len(relayUrls))
|
||||
flush := func() {
|
||||
for _, line := range lines {
|
||||
for _, part := range line {
|
||||
os.Stderr.Write(part)
|
||||
}
|
||||
os.Stderr.Write([]byte{'\n'})
|
||||
}
|
||||
}
|
||||
render := func() {
|
||||
clearLines(len(lines))
|
||||
flush()
|
||||
}
|
||||
flush()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(relayUrls))
|
||||
mu := sync.Mutex{} // guards lines and relays, written from the goroutines below
|
||||
for i, url := range relayUrls {
|
||||
lines[i] = make([][]byte, 1, 2)
|
||||
logthis := func(s string, args ...any) {
|
||||
mu.Lock()
|
||||
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
|
||||
render()
|
||||
mu.Unlock()
|
||||
}
|
||||
colorizepreamble := func(c func(string, ...any) string) {
|
||||
mu.Lock()
|
||||
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
|
||||
mu.Unlock()
|
||||
}
|
||||
colorizepreamble(color.CyanString)
|
||||
|
||||
go func() {
|
||||
relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis)
|
||||
if relay != nil {
|
||||
mu.Lock()
|
||||
relays = append(relays, relay)
|
||||
mu.Unlock()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
} else {
|
||||
// simple flow
|
||||
for _, url := range relayUrls {
|
||||
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
|
||||
relay := connectToSingleRelay(ctx, c, url, nil, log)
|
||||
if relay != nil {
|
||||
relays = append(relays, relay)
|
||||
}
|
||||
log("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
func connectToSingleRelay(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
url string,
|
||||
colorizepreamble func(c func(string, ...any) string),
|
||||
logthis func(s string, args ...any),
|
||||
) *nostr.Relay {
|
||||
nm := nostr.NormalizeURL(url)
|
||||
|
||||
relay, ok := sys.Pool.Relays.Load(nm)
|
||||
if !ok || relay == nil || !relay.IsConnected() {
|
||||
connectCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
if relay, err = nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions); err != nil {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis(clampError(err, len(url)+12))
|
||||
return nil
|
||||
}
|
||||
|
||||
sys.Pool.Relays.Store(nm, relay)
|
||||
go func(r *nostr.Relay, relayURL string) {
|
||||
<-r.Context().Done()
|
||||
if current, ok := sys.Pool.Relays.Load(relayURL); ok && current == r {
|
||||
sys.Pool.Relays.Delete(relayURL)
|
||||
}
|
||||
}(relay, nm)
|
||||
}
|
||||
|
||||
if c.Bool("force-pre-auth") {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(color.YellowString)
|
||||
}
|
||||
logthis("waiting for auth challenge... ")
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
for range 5 {
|
||||
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
challengeTag := authEvent.Tags.Find("challenge")
|
||||
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
|
||||
return fmt.Errorf("auth not received yet *****") // what a giant hack
|
||||
}
|
||||
return authSigner(ctx, c, logthis, authEvent)
|
||||
}); err == nil {
|
||||
// auth succeeded
|
||||
goto preauthSuccess
|
||||
} else {
|
||||
// auth failed
|
||||
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis(err.Error())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis("failed to get an AUTH challenge in enough time.")
|
||||
return nil
|
||||
}
|
||||
|
||||
preauthSuccess:
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.successf)
|
||||
}
|
||||
logthis("ok.")
|
||||
return relay
|
||||
}
|
||||
|
||||
func clearLines(lineCount int) {
|
||||
for i := 0; i < lineCount; i++ {
|
||||
os.Stderr.Write([]byte("\033[0A\033[2K\r"))
|
||||
}
|
||||
}
|
||||
|
||||
func supportsDynamicMultilineMagic() bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return false
|
||||
}
|
||||
if !term.IsTerminal(0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
return false
|
||||
}
|
||||
|
||||
width, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if width < 110 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
|
||||
log(msg+"\n", args...)
|
||||
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
|
||||
@@ -212,6 +391,334 @@ func randString(n int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func leftPadKey(k string) string {
|
||||
return strings.Repeat("0", 64-len(k)) + k
|
||||
func unwrapAll(err error) error {
|
||||
low := err
|
||||
for n := low; n != nil; n = errors.Unwrap(low) {
|
||||
low = n
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
func clampMessage(msg string, prefixAlreadyPrinted int) string {
|
||||
termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
|
||||
|
||||
prf := "expected handshake response status code 101 but got "
|
||||
if len(msg) > len(prf) && msg[0:len(prf)] == prf {
|
||||
msg = "status " + msg[len(prf):]
|
||||
}
|
||||
|
||||
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
|
||||
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func clampError(err error, prefixAlreadyPrinted int) string {
|
||||
termSize, _, _ := term.GetSize(0)
|
||||
msg := err.Error()
|
||||
if len(msg) > termSize-prefixAlreadyPrinted {
|
||||
err = unwrapAll(err)
|
||||
msg = clampMessage(err.Error(), prefixAlreadyPrinted)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func askConfirmation(msg string) bool {
|
||||
if isPiped() {
|
||||
tty, err := tty.Open()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer tty.Close()
|
||||
|
||||
log(color.YellowString(msg))
|
||||
answer, err := tty.ReadString()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// print newline after password input
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
answer = strings.TrimSpace(string(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
} else {
|
||||
config := &readline.Config{
|
||||
Stdout: color.Error,
|
||||
Prompt: color.YellowString(msg),
|
||||
InterruptPrompt: "^C",
|
||||
DisableAutoSaveHistory: true,
|
||||
EnableMask: false,
|
||||
MaskRune: '*',
|
||||
}
|
||||
|
||||
rl, err := readline.NewEx(config)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
answer, err := rl.Readline()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
func parsePubKey(value string) (nostr.PubKey, error) {
|
||||
if nip05.IsValidIdentifier(value) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
pp, err := nip05.QueryIdentifier(ctx, value)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, err
|
||||
}
|
||||
return pp.PublicKey, nil
|
||||
}
|
||||
|
||||
pk, err := nostr.PubKeyFromHex(value)
|
||||
if err == nil {
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
if prefix, decoded, err := nip19.Decode(value); err == nil {
|
||||
switch prefix {
|
||||
case "npub":
|
||||
if pk, ok := decoded.(nostr.PubKey); ok {
|
||||
return pk, nil
|
||||
}
|
||||
case "nprofile":
|
||||
if profile, ok := decoded.(nostr.ProfilePointer); ok {
|
||||
return profile.PublicKey, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
|
||||
}
|
||||
|
||||
func parseSecretKey(input string) (nostr.SecretKey, error) {
|
||||
if prefix, ski, err := nip19.Decode(input); err == nil && prefix == "nsec" {
|
||||
return ski.(nostr.SecretKey), nil
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(input)
|
||||
if err != nil {
|
||||
return nostr.SecretKey{}, fmt.Errorf("invalid secret key: %w", err)
|
||||
}
|
||||
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
func parseEventID(value string) (nostr.ID, error) {
|
||||
id, err := nostr.IDFromHex(value)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
if prefix, decoded, err := nip19.Decode(value); err == nil {
|
||||
switch prefix {
|
||||
case "note":
|
||||
if event, ok := decoded.(nostr.EventPointer); ok {
|
||||
return event.ID, nil
|
||||
}
|
||||
case "nevent":
|
||||
if event, ok := decoded.(nostr.EventPointer); ok {
|
||||
return event.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
|
||||
}
|
||||
|
||||
func decodeTagValue(value string, letter rune) string {
|
||||
letter = unicode.ToLower(letter)
|
||||
|
||||
if letter == 'p' {
|
||||
if nip05.IsValidIdentifier(value) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
pp, err := nip05.QueryIdentifier(ctx, value)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return pp.PublicKey.Hex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
|
||||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
|
||||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
|
||||
if ptr, err := nip19.ToPointer(value); err == nil {
|
||||
return ptr.AsTagReference()
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
|
||||
fullpath := filepath.Join(os.TempDir(), filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
if wipe {
|
||||
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("failed to remove temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmp.WriteString(initialContent); err != nil {
|
||||
tmp.Close()
|
||||
return "", fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
editor := strings.TrimSpace(os.Getenv("VISUAL"))
|
||||
if editor == "" {
|
||||
editor = strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
}
|
||||
if editor == "" {
|
||||
editor = "edit"
|
||||
}
|
||||
|
||||
parts := strings.Fields(editor)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
|
||||
}
|
||||
|
||||
args := append(parts[1:], tmp.Name())
|
||||
cmd := exec.Command(parts[0], args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("editor command failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tmp.Name())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read edited temp file: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func clampWithEllipsis(s string, size int) string {
|
||||
if len(s) <= size {
|
||||
return s
|
||||
}
|
||||
return s[0:size-1] + "…"
|
||||
}
|
||||
|
||||
var (
|
||||
schemaURI string
|
||||
fetchSchemaOnce sync.Once
|
||||
schemaCache schema.Schema
|
||||
schemaErrCache error
|
||||
)
|
||||
|
||||
func getSchema() (schema.Schema, error) {
|
||||
fetchSchemaOnce.Do(func() {
|
||||
if strings.HasPrefix(schemaURI, "http") {
|
||||
schemaCache, schemaErrCache = schema.FetchSchemaFromURL(schemaURI)
|
||||
} else {
|
||||
schemaCache, schemaErrCache = schema.NewSchemaFromFile(schemaURI)
|
||||
}
|
||||
})
|
||||
return schemaCache, schemaErrCache
|
||||
}
|
||||
|
||||
func stringToKind(value string) (nostr.Kind, error) {
|
||||
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
|
||||
if n > 65535 {
|
||||
return 0, fmt.Errorf("kind number %d is out of range (0-65535)", n)
|
||||
}
|
||||
return nostr.Kind(n), nil
|
||||
}
|
||||
|
||||
// find kind from name
|
||||
sch, err := getSchema()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
fuzzyWords := make([]string, 0, len(sch.Kinds))
|
||||
fuzzyIndexes := make([]string, 0, len(sch.Kinds))
|
||||
|
||||
// exact match
|
||||
for k, ks := range sch.Kinds {
|
||||
fuzzyWords = append(fuzzyWords, ks.Description)
|
||||
fuzzyIndexes = append(fuzzyIndexes, k)
|
||||
|
||||
if strings.EqualFold(ks.Description, value) {
|
||||
return ks.Kind, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzy match
|
||||
result := fuzzy.RankFindNormalizedFold(value, fuzzyWords)
|
||||
bestDesc := "<none>"
|
||||
bestDist := "-"
|
||||
|
||||
if len(result) > 0 {
|
||||
if bd := result[0].Distance; bd < 26 {
|
||||
return sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Kind, nil
|
||||
} else {
|
||||
bestDesc = sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Description
|
||||
bestDist = strconv.Itoa(bd)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown kind: %q (closest: %q, distance: %s)", value, bestDesc, bestDist)
|
||||
}
|
||||
|
||||
var colors = struct {
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
italicf func(string, ...any) string
|
||||
bold func(...any) string
|
||||
boldf func(string, ...any) string
|
||||
underline func(...any) string
|
||||
underlinef func(string, ...any) string
|
||||
error func(...any) string
|
||||
errorf func(string, ...any) string
|
||||
success func(...any) string
|
||||
successf func(string, ...any) string
|
||||
}{
|
||||
color.New(color.Reset).Print,
|
||||
color.New(color.Italic).Sprint,
|
||||
color.New(color.Italic).Sprintf,
|
||||
color.New(color.Bold).Sprint,
|
||||
color.New(color.Bold).Sprintf,
|
||||
color.New(color.Underline).Sprint,
|
||||
color.New(color.Underline).Sprintf,
|
||||
color.New(color.Bold, color.FgHiRed).Sprint,
|
||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||
color.New(color.Bold, color.FgHiGreen).Sprintf,
|
||||
}
|
||||
|
||||
func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag {
|
||||
total := 0
|
||||
for _, s := range flagSlices {
|
||||
total += len(s)
|
||||
}
|
||||
total += len(extraFlags)
|
||||
result := make([]cli.Flag, 0, total)
|
||||
for _, s := range flagSlices {
|
||||
result = append(result, s...)
|
||||
}
|
||||
result = append(result, extraFlags...)
|
||||
return result
|
||||
}
|
||||
|
||||
214
helpers_key.go
214
helpers_key.go
@@ -2,118 +2,123 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/nip46"
|
||||
"fiatjaf.com/nostr/nip49"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/denisbrodbeck/machineid"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/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"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var defaultKeyFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
|
||||
DefaultText: "the key '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 NIP-46 bunkers",
|
||||
DefaultText: "a random key",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
|
||||
},
|
||||
// the default secret key we will use when "--sec" is not provided
|
||||
func defaultKey() nostr.SecretKey {
|
||||
mid, err := machineid.ID()
|
||||
if err != nil {
|
||||
k := nostr.SecretKey{}
|
||||
k[29] = 'n'
|
||||
k[30] = 'a'
|
||||
k[31] = 'k'
|
||||
return k
|
||||
}
|
||||
|
||||
return sha256.Sum256([]byte(mid))
|
||||
}
|
||||
|
||||
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, string, error) {
|
||||
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
|
||||
key, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, nostr.SecretKey{}, err
|
||||
}
|
||||
|
||||
var kr nostr.Keyer
|
||||
if bunker != nil {
|
||||
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
|
||||
if bunker == nil {
|
||||
kr = keyer.NewPlainKeySigner(key)
|
||||
} else {
|
||||
kr, err = keyer.NewPlainKeySigner(key)
|
||||
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
|
||||
}
|
||||
|
||||
return kr, key, err
|
||||
return kr, key, nil
|
||||
}
|
||||
|
||||
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (string, *nip46.BunkerClient, error) {
|
||||
var err error
|
||||
|
||||
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
|
||||
sec := c.String("sec")
|
||||
|
||||
if strings.HasPrefix(sec, "bunker://") {
|
||||
// it's a bunker
|
||||
bunkerURL := sec
|
||||
clientKey := 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) {
|
||||
|
||||
clientKey := getSecretKey(c, "connect-as")
|
||||
logverbose("[nip46]: connecting to %s with client key %s\n", bunkerURL, clientKey.Hex())
|
||||
|
||||
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, sys.Pool, func(s string) {
|
||||
log(color.CyanString("[nip46]: open the following URL: %s"), s)
|
||||
})
|
||||
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 err != nil {
|
||||
return nostr.SecretKey{}, nil, fmt.Errorf("failed to connect to %s: %w", bunkerURL, err)
|
||||
}
|
||||
|
||||
return nostr.SecretKey{}, bunker, err
|
||||
}
|
||||
|
||||
if c.Bool("prompt-sec") {
|
||||
if isPiped() {
|
||||
return "", nil, 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 = 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)
|
||||
return nostr.SecretKey{}, nil, fmt.Errorf("failed to get secret key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(sec, "ncryptsec1") {
|
||||
sec, err = promptDecrypt(sec)
|
||||
sk, err := promptDecrypt(sec)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
return nostr.SecretKey{}, 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)
|
||||
return sk, nil, nil
|
||||
}
|
||||
|
||||
if ok := nostr.IsValid32ByteHex(sec); !ok {
|
||||
return "", nil, fmt.Errorf("invalid secret key")
|
||||
}
|
||||
|
||||
return sec, nil, nil
|
||||
sk, err := parseSecretKey(sec)
|
||||
return sk, nil, err
|
||||
}
|
||||
|
||||
func promptDecrypt(ncryptsec string) (string, error) {
|
||||
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
|
||||
log("%s auth failed: %s\n", colors.errorf(cleanUrl), err)
|
||||
}
|
||||
}()
|
||||
|
||||
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
|
||||
return fmt.Errorf("auth required, but --auth flag not given")
|
||||
}
|
||||
|
||||
var kr nostr.Keyer
|
||||
|
||||
if nip46.IsBunkerClientOperation(ctx) {
|
||||
kr = keyer.NewPlainKeySigner(getSecretKey(c, "connect-as"))
|
||||
} else {
|
||||
kr, _, err = gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
log("authenticating as %s... ", color.YellowString("%s…", pk.Hex()[0:16]))
|
||||
|
||||
return kr.SignEvent(ctx, authEvent)
|
||||
}
|
||||
|
||||
func promptDecrypt(ncryptsec string) (nostr.SecretKey, error) {
|
||||
for i := 1; i < 4; i++ {
|
||||
var attemptStr string
|
||||
if i > 1 {
|
||||
@@ -121,7 +126,7 @@ func promptDecrypt(ncryptsec string) (string, error) {
|
||||
}
|
||||
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nostr.SecretKey{}, err
|
||||
}
|
||||
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||
if err != nil {
|
||||
@@ -129,33 +134,62 @@ func promptDecrypt(ncryptsec string) (string, error) {
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
return "", fmt.Errorf("couldn't decrypt private key")
|
||||
return nostr.SecretKey{}, 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: '*',
|
||||
}
|
||||
if isPiped() {
|
||||
// use TTY method when stdin is piped
|
||||
tty, err := tty.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe on this system (failed to open /dev/tty: %w), try again without --prompt-sec or provide the key via --sec or NOSTR_SECRET_KEY environment variable", err)
|
||||
}
|
||||
defer tty.Close()
|
||||
for {
|
||||
// print the prompt to stderr so it's visible to the user
|
||||
log(color.YellowString(msg))
|
||||
|
||||
rl, err := readline.NewEx(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// read password from TTY with masking
|
||||
password, err := tty.ReadPassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
answer, err := rl.Readline()
|
||||
// print newline after password input
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
answer := strings.TrimSpace(string(password))
|
||||
if shouldAskAgain != nil && shouldAskAgain(answer) {
|
||||
continue
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
} else {
|
||||
// use normal readline method when stdin is not piped
|
||||
config := &readline.Config{
|
||||
Stdout: os.Stderr,
|
||||
Prompt: color.YellowString(msg),
|
||||
InterruptPrompt: "^C",
|
||||
DisableAutoSaveHistory: true,
|
||||
EnableMask: true,
|
||||
MaskRune: '*',
|
||||
}
|
||||
|
||||
rl, err := readline.NewEx(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
answer = strings.TrimSpace(answer)
|
||||
if shouldAskAgain != nil && shouldAskAgain(answer) {
|
||||
continue
|
||||
|
||||
for {
|
||||
answer, err := rl.Readline()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
answer = strings.TrimSpace(answer)
|
||||
if shouldAskAgain != nil && shouldAskAgain(answer) {
|
||||
continue
|
||||
}
|
||||
return answer, err
|
||||
}
|
||||
return answer, err
|
||||
}
|
||||
}
|
||||
|
||||
73
install.sh
Executable file
73
install.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
# detect OS
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*) echo "linux";;
|
||||
Darwin*) echo "darwin";;
|
||||
FreeBSD*) echo "freebsd";;
|
||||
MINGW*|MSYS*|CYGWIN*) echo "windows";;
|
||||
*)
|
||||
echo "error: unsupported OS $(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# detect architecture
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64";;
|
||||
aarch64|arm64) echo "arm64";;
|
||||
riscv64) echo "riscv64";;
|
||||
*)
|
||||
echo "error: unsupported architecture $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# set install directory
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
# detect platform
|
||||
OS=$(detect_os)
|
||||
ARCH=$(detect_arch)
|
||||
|
||||
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
|
||||
|
||||
# check if curl is available
|
||||
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
|
||||
|
||||
# get latest release tag
|
||||
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
|
||||
TAG="${RELEASE_INFO#*\"tag_name\"}"
|
||||
TAG="${TAG#*\"}"
|
||||
TAG="${TAG%%\"*}"
|
||||
|
||||
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
|
||||
|
||||
# construct download URL
|
||||
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
|
||||
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
|
||||
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
|
||||
|
||||
# create install directory and download
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
TARGET_PATH="$INSTALL_DIR/nak"
|
||||
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
|
||||
|
||||
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
|
||||
chmod +x "$TARGET_PATH"
|
||||
echo "installed nak $TAG to $TARGET_PATH"
|
||||
|
||||
# check if install dir is in PATH
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*) ;;
|
||||
*) echo "note: add $INSTALL_DIR to your PATH" ;;
|
||||
esac
|
||||
else
|
||||
echo "error: download failed from $DOWNLOAD_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
101
jq.go
Normal file
101
jq.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
const eventJQPrelude = `
|
||||
def tags(tagName): .tags | map(select(.[0] == tagName));
|
||||
def tag(tagName): tags(tagName) | .[0];
|
||||
def value(tagName): tag(tagName)[1];
|
||||
def has(tagName): (tags(tagName) | length) > 0;
|
||||
def hasnt(tagName): (tags(tagName) | length) == 0;
|
||||
def has_value(tagName; tagValue): tags(tagName) | map(select(.[1] == tagValue)) | length > 0;
|
||||
def date: .created_at | gmtime | strftime("%Y-%m-%d");
|
||||
def time: .created_at | gmtime | strftime("%H:%M:%S");
|
||||
def datetime: .created_at | gmtime | strftime("%Y-%m-%dT%H:%M:%SZ");
|
||||
`
|
||||
|
||||
// jqProcessor runs the compiled jq expression against an event and returns the
|
||||
// rendered output string. When matches is false the event should be skipped.
|
||||
type jqProcessor func(nostr.Event) (out string, matches bool, err error)
|
||||
|
||||
func jqPrepare(expr string, raw bool) (jqProcessor, error) {
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query, err := gojq.Parse(eventJQPrelude + expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
}
|
||||
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
|
||||
}
|
||||
|
||||
return func(evt nostr.Event) (string, bool, error) {
|
||||
input, err := toJQInput(evt)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
iter := code.Run(input)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if err, ok := v.(error); ok {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if jqTruthy(v) {
|
||||
return jqStringify(v, raw), true, nil
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// jqStringify renders a jq result value. In raw mode string results are printed
|
||||
// without JSON quoting (like `jq -r`); non-string results are always
|
||||
// JSON-encoded.
|
||||
func jqStringify(v any, raw bool) string {
|
||||
if raw {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
out, _ := json.MarshalToString(v)
|
||||
return out
|
||||
}
|
||||
|
||||
func toJQInput(v any) (any, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal jq input: %w", err)
|
||||
}
|
||||
|
||||
var input any
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal jq input: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func jqTruthy(v any) bool {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return v
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
5
justfile
Normal file
5
justfile
Normal file
@@ -0,0 +1,5 @@
|
||||
test:
|
||||
#!/usr/bin/env fish
|
||||
for test in (go test -list .)
|
||||
go test -run=$test -v
|
||||
end
|
||||
197
key.go
197
key.go
@@ -6,26 +6,29 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip49"
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
"github.com/nbd-wtf/go-nostr/nip49"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var key = &cli.Command{
|
||||
Name: "key",
|
||||
Usage: "operations on secret keys: generate, derive, encrypt, decrypt.",
|
||||
Usage: "operations on secret keys: generate, derive, encrypt, decrypt",
|
||||
Description: ``,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
generate,
|
||||
public,
|
||||
expand,
|
||||
encryptKey,
|
||||
decryptKey,
|
||||
combine,
|
||||
validate,
|
||||
defaultCommand,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -35,8 +38,8 @@ var generate = &cli.Command{
|
||||
Description: ``,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
sec := nostr.GeneratePrivateKey()
|
||||
stdout(sec)
|
||||
sec := nostr.Generate()
|
||||
stdout(sec.Hex())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -54,9 +57,22 @@ var public = &cli.Command{
|
||||
},
|
||||
},
|
||||
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)
|
||||
args := c.Args().Slice()
|
||||
if len(args) == 0 && !isPiped() {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
stdout(pk.Hex())
|
||||
return nil
|
||||
}
|
||||
|
||||
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, args) {
|
||||
_, pk := btcec.PrivKeyFromBytes(sk[:])
|
||||
|
||||
if c.Bool("with-parity") {
|
||||
stdout(hex.EncodeToString(pk.SerializeCompressed()))
|
||||
@@ -68,10 +84,41 @@ var public = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var expand = &cli.Command{
|
||||
Name: "expand",
|
||||
Usage: "left-pads a hex key to 64 characters",
|
||||
Description: ``,
|
||||
ArgsUsage: "[key]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
keyhex = strings.TrimSpace(keyhex)
|
||||
if keyhex == "" {
|
||||
continue
|
||||
}
|
||||
if len(keyhex) > 64 {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
|
||||
continue
|
||||
}
|
||||
check := keyhex
|
||||
if len(check)%2 == 1 {
|
||||
check = "0" + check
|
||||
}
|
||||
if _, err := hex.DecodeString(check); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
keyhex = strings.ToLower(keyhex)
|
||||
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var encryptKey = &cli.Command{
|
||||
Name: "encrypt",
|
||||
Usage: "encrypts a secret key and prints an ncryptsec code",
|
||||
Description: `uses the NIP-49 standard.`,
|
||||
Description: `uses the nip49 standard.`,
|
||||
ArgsUsage: "<secret> <password>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
@@ -110,7 +157,7 @@ var encryptKey = &cli.Command{
|
||||
var decryptKey = &cli.Command{
|
||||
Name: "decrypt",
|
||||
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
|
||||
Description: `uses the NIP-49 standard.`,
|
||||
Description: `uses the nip49 standard.`,
|
||||
ArgsUsage: "<ncryptsec-code> <password>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
@@ -123,31 +170,33 @@ var decryptKey = &cli.Command{
|
||||
if password == "" {
|
||||
return fmt.Errorf("no password given")
|
||||
}
|
||||
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||
sk, err := nip49.Decrypt(ncryptsec, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %s", err)
|
||||
}
|
||||
stdout(sec)
|
||||
stdout(sk.Hex())
|
||||
return nil
|
||||
case 1:
|
||||
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
|
||||
ncryptsec = arg
|
||||
if res, err := promptDecrypt(ncryptsec); err != nil {
|
||||
if sk, err := promptDecrypt(ncryptsec); err != nil {
|
||||
return err
|
||||
} else {
|
||||
stdout(res)
|
||||
stdout(sk.Hex())
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
password = c.Args().Get(0)
|
||||
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
|
||||
sec, err := nip49.Decrypt(ncryptsec, password)
|
||||
// the ncryptsec codes come from stdin
|
||||
for ncryptsec := range getStdinLinesOrArgumentsFromSlice(nil) {
|
||||
sk, err := nip49.Decrypt(ncryptsec, password)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
|
||||
continue
|
||||
}
|
||||
stdout(sec)
|
||||
stdout(sk.Hex())
|
||||
}
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
@@ -156,6 +205,64 @@ var decryptKey = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var validate = &cli.Command{
|
||||
Name: "validate",
|
||||
Usage: "validates a public key by attempting to parse it",
|
||||
Description: `Accepts public keys in hex (64 characters) or npub format.
|
||||
Returns error if key is invalid, otherwise exits successfully.`,
|
||||
ArgsUsage: "[pubkey...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
pk = strings.TrimSpace(pk)
|
||||
if pk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var pkBytes []byte
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(pk, "npub1") {
|
||||
_, data, err := nip19.Decode(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
|
||||
continue
|
||||
}
|
||||
tmp := data.(nostr.PubKey)
|
||||
pkBytes = tmp[:]
|
||||
} else {
|
||||
pkBytes, err = hex.DecodeString(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex: %s", err)
|
||||
continue
|
||||
}
|
||||
if len(pkBytes) != 32 {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey length: expected 32 bytes, got %d", len(pkBytes))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, err = btcec.ParsePubKey(append([]byte{0x02}, pkBytes...))
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var defaultCommand = &cli.Command{
|
||||
Name: "default",
|
||||
Usage: "prints the default secret key",
|
||||
Description: `the default secret key is generated differently for each machine (or falls back to a hardcoded one in case of failure), it is not save in any way, but this command makes it available if that is needed.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
stdout(nip19.EncodeNsec(defaultKey()))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var combine = &cli.Command{
|
||||
Name: "combine",
|
||||
Usage: "combines two or more pubkeys using musig2",
|
||||
@@ -184,15 +291,13 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
|
||||
|
||||
for i, keyhex := range result.Keys {
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
pk32, err := parsePubKey(keyhex)
|
||||
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
|
||||
result.Keys[i] = pk32.Hex()
|
||||
|
||||
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
|
||||
group := make([]*btcec.PublicKey, 2)
|
||||
for i, prefix := range []byte{0x02, 0x03} {
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
|
||||
if err != nil {
|
||||
log("error parsing key %s: %s", keyhex, err)
|
||||
continue
|
||||
@@ -200,7 +305,16 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
group[i] = pubk
|
||||
}
|
||||
keyGroups = append(keyGroups, group)
|
||||
} else /* assume it's 33 */ {
|
||||
continue
|
||||
}
|
||||
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
if len(keyb) == 33 {
|
||||
result.Keys[i] = hex.EncodeToString(keyb[1:])
|
||||
|
||||
pubk, err := btcec.ParsePubKey(keyb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
@@ -209,7 +323,15 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
|
||||
// remove the leading byte from the output just so it is all uniform
|
||||
result.Keys[i] = result.Keys[i][2:]
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid key %s: expected 32 or 33 bytes hex", keyhex)
|
||||
}
|
||||
|
||||
if len(keyGroups) < 2 {
|
||||
return fmt.Errorf("at least two pubkeys are required")
|
||||
}
|
||||
|
||||
result.Combinations = make([]Combination, 0, 16)
|
||||
@@ -264,27 +386,32 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
},
|
||||
}
|
||||
|
||||
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan string {
|
||||
ch := make(chan string)
|
||||
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan nostr.SecretKey {
|
||||
ch := make(chan nostr.SecretKey)
|
||||
go func() {
|
||||
for sec := range getStdinLinesOrArgumentsFromSlice(keys) {
|
||||
if sec == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var sk nostr.SecretKey
|
||||
if strings.HasPrefix(sec, "nsec1") {
|
||||
_, data, err := nip19.Decode(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
|
||||
continue
|
||||
}
|
||||
sec = data.(string)
|
||||
sk = data.(nostr.SecretKey)
|
||||
} else {
|
||||
var err error
|
||||
sk, err = nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sec = leftPadKey(sec)
|
||||
if !nostr.IsValid32ByteHex(sec) {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key")
|
||||
continue
|
||||
}
|
||||
ch <- sec
|
||||
|
||||
ch <- sk
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
64
kind.go
Normal file
64
kind.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var kindCmd = &cli.Command{
|
||||
Name: "kind",
|
||||
Usage: "look up information about a Nostr event kind",
|
||||
Description: `takes a kind number or a kind name and prints information about that kind from the registry of kinds schema.
|
||||
|
||||
example:
|
||||
nak kind 1
|
||||
nak kind "text note"
|
||||
nak kind "git meta"
|
||||
nak kind "fav relays"
|
||||
nak kind 30023`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("requires exactly one argument: kind number or name")
|
||||
}
|
||||
|
||||
input := c.Args().First()
|
||||
|
||||
// resolve input to a kind number
|
||||
var k nostr.Kind
|
||||
if n, err := strconv.ParseUint(input, 10, 16); err == nil && n >= 0 {
|
||||
k = nostr.Kind(n)
|
||||
} else {
|
||||
resolved, err := stringToKind(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve kind: %w", err)
|
||||
}
|
||||
k = resolved
|
||||
}
|
||||
|
||||
sch, err := getSchema()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ks := sch.Kinds[strconv.Itoa(int(k.Num()))]
|
||||
|
||||
j, _ := json.MarshalIndent(ks, "", " ")
|
||||
stdout(string(j))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
46
lmdb.go
Normal file
46
lmdb.go
Normal file
@@ -0,0 +1,46 @@
|
||||
//go:build linux && !riscv64 && !arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fiatjaf.com/nostr/eventstore/lmdb"
|
||||
"fiatjaf.com/nostr/eventstore/nullstore"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"fiatjaf.com/nostr/sdk/hints/lmdbh"
|
||||
lmdbkv "fiatjaf.com/nostr/sdk/kvstore/lmdb"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
configPath := c.String("config-path")
|
||||
if configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
if hdb, err := lmdbh.NewLMDBHints(hintsPath); err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
} else {
|
||||
sys.Hints = hdb
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
}
|
||||
202
main.go
202
main.go
@@ -2,59 +2,221 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var version string = "debug"
|
||||
var authFlags = []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
Category: CATEGORY_AUTH,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force-pre-auth",
|
||||
Aliases: []string{"fpa"},
|
||||
Usage: "after connecting, wait for a nip42 \"AUTH\" message to be received, act on it and only then send the query",
|
||||
Category: CATEGORY_AUTH,
|
||||
},
|
||||
}
|
||||
|
||||
var defaultKeyFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
|
||||
Value: defaultKey().Hex(),
|
||||
DefaultText: "a default key specific to your machine, see it with `nak key default`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "prompt-sec",
|
||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Hidden: true,
|
||||
},
|
||||
&SecretKeyFlag{
|
||||
Name: "connect-as",
|
||||
Usage: "private key to use when communicating with nip46 bunkers",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
|
||||
Value: defaultKey(),
|
||||
DefaultText: "the default key (see `nak key default`)",
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
version string = "debug"
|
||||
isVerbose bool = false
|
||||
)
|
||||
|
||||
var app = &cli.Command{
|
||||
Name: "nak",
|
||||
Suggest: true,
|
||||
UseShortOptionHandling: true,
|
||||
AllowFlagsAfterArguments: true,
|
||||
Usage: "the nostr army knife command-line tool",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
eventCmd,
|
||||
req,
|
||||
count,
|
||||
filterCmd,
|
||||
fetch,
|
||||
event,
|
||||
count,
|
||||
decode,
|
||||
encode,
|
||||
key,
|
||||
kindCmd,
|
||||
verify,
|
||||
relay,
|
||||
admin,
|
||||
bunker,
|
||||
serve,
|
||||
blossomCmd,
|
||||
dekey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
gift,
|
||||
outboxCmd,
|
||||
wallet,
|
||||
mcpServer,
|
||||
curl,
|
||||
fsCmd,
|
||||
publish,
|
||||
nsite,
|
||||
git,
|
||||
group,
|
||||
podcast,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
profile,
|
||||
validateCmd,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
||||
Aliases: []string{"q"},
|
||||
Persistent: true,
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
q := c.Count("quiet")
|
||||
if q >= 1 {
|
||||
log = func(msg string, args ...any) {}
|
||||
if q >= 2 {
|
||||
stdout = func(_ ...any) (int, error) { return 0, nil }
|
||||
Flags: combineFlags([][]cli.Flag{
|
||||
{
|
||||
&cli.StringFlag{
|
||||
Name: "config-path",
|
||||
Hidden: true,
|
||||
Value: (func() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config/nak")
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
||||
Aliases: []string{"q"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
q := c.Count("quiet")
|
||||
if q >= 1 {
|
||||
log = func(msg string, args ...any) {}
|
||||
if q >= 2 {
|
||||
stdout = func(_ ...any) {}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Usage: "print more stuff than normally",
|
||||
Aliases: []string{"v"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
v := c.Count("verbose")
|
||||
if v >= 1 {
|
||||
logverbose = log
|
||||
isVerbose = true
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "connect-timeout",
|
||||
Usage: "timeout for connecting to relays",
|
||||
Value: connectTimeout,
|
||||
Action: func(ctx context.Context, c *cli.Command, d time.Duration) error {
|
||||
connectTimeout = d
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
},
|
||||
defaultKeyFlags,
|
||||
authFlags,
|
||||
}),
|
||||
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||
sys = sdk.NewSystem()
|
||||
|
||||
setupLocalDatabases(c, sys)
|
||||
|
||||
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
|
||||
sys.Pool.EventMiddleware = sys.TrackEventHints
|
||||
sys.Pool.RelayOptions = nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
|
||||
}
|
||||
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {
|
||||
if strings.HasPrefix(s, "authenticating as") {
|
||||
cleanUrl, _ := strings.CutPrefix(
|
||||
nip42.GetRelayURLFromAuthEvent(*authEvent),
|
||||
"wss://",
|
||||
)
|
||||
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
|
||||
}
|
||||
log(s+"\n", args...)
|
||||
}, authEvent)
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cli.VersionFlag = &cli.BoolFlag{
|
||||
Name: "version",
|
||||
Usage: "prints the version",
|
||||
}
|
||||
cli.HelpFlag = &cli.BoolFlag{
|
||||
Name: "help",
|
||||
Usage: "shows help",
|
||||
HideDefault: true,
|
||||
Local: true,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer colors.reset()
|
||||
|
||||
// a megahack to enable this curl command proxy
|
||||
if len(os.Args) > 2 && os.Args[1] == "curl" {
|
||||
if err := realCurl(); err != nil {
|
||||
log(color.YellowString(err.Error()) + "\n")
|
||||
colors.reset()
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||
stdout(err)
|
||||
log("%s\n", color.RedString(err.Error()))
|
||||
colors.reset()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
352
mcp.go
Normal file
352
mcp.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var mcpServer = &cli.Command{
|
||||
Name: "mcp",
|
||||
Usage: "pander to the AI gods",
|
||||
Description: ``,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
s := server.NewMCPServer(
|
||||
"nak",
|
||||
version,
|
||||
)
|
||||
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.AddTool(mcp.NewTool("publish_note",
|
||||
mcp.WithDescription("publish a short note event to Nostr with the given text content"),
|
||||
mcp.WithString("content", mcp.Description("arbitrary string to be published"), mcp.Required()),
|
||||
mcp.WithString("relay", mcp.Description("relay to publish the note to")),
|
||||
mcp.WithString("mention", mcp.Description("nostr user's public key to be mentioned")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
content := required[string](r, "content")
|
||||
mention, _ := optional[string](r, "mention")
|
||||
relay, _ := optional[string](r, "relay")
|
||||
|
||||
var relays []string
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: 1,
|
||||
Tags: nostr.Tags{{"client", "goose/nak"}},
|
||||
Content: content,
|
||||
CreatedAt: nostr.Now(),
|
||||
}
|
||||
|
||||
if mention != "" {
|
||||
pk, err := nostr.PubKeyFromHex(mention)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex()})
|
||||
// their inbox relays
|
||||
relays = sys.FetchInboxRelays(ctx, pk, 3)
|
||||
}
|
||||
|
||||
if err := keyer.SignEvent(ctx, &evt); err != nil {
|
||||
return mcp.NewToolResultError("it was impossible to sign the event, so we can't proceed to publishwith publishing it."), nil
|
||||
}
|
||||
|
||||
// our write relays
|
||||
relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...)
|
||||
|
||||
if len(relays) == 0 {
|
||||
relays = []string{"nos.lol", "relay.damus.io"}
|
||||
}
|
||||
|
||||
// extra relay specified
|
||||
if relay != "" {
|
||||
relays = append(relays, relay)
|
||||
}
|
||||
|
||||
result := strings.Builder{}
|
||||
result.WriteString(
|
||||
fmt.Sprintf("the event we generated has id '%s', kind '%d' and is signed by pubkey '%s'. ",
|
||||
evt.ID,
|
||||
evt.Kind,
|
||||
evt.PubKey,
|
||||
),
|
||||
)
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
|
||||
if res.Error != nil {
|
||||
result.WriteString(
|
||||
fmt.Sprintf("there was an error publishing the event to the relay %s. ",
|
||||
res.RelayURL),
|
||||
)
|
||||
} else {
|
||||
result.WriteString(
|
||||
fmt.Sprintf("the event was successfully published to the relay %s. ",
|
||||
res.RelayURL),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(result.String()), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("resolve_nostr_uri",
|
||||
mcp.WithDescription("resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
|
||||
mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
uri := required[string](r, "uri")
|
||||
if strings.HasPrefix(uri, "nostr:") {
|
||||
uri = uri[6:]
|
||||
}
|
||||
|
||||
prefix, data, err := nip19.Decode(uri)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("this Nostr uri is invalid"), nil
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case "npub":
|
||||
pm := sys.FetchProfileMetadata(ctx, data.(nostr.PubKey))
|
||||
return mcp.NewToolResultText(
|
||||
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
|
||||
pm.ShortName(), pm.PubKey),
|
||||
), nil
|
||||
case "nprofile":
|
||||
pm, _ := sys.FetchProfileFromInput(ctx, uri)
|
||||
return mcp.NewToolResultText(
|
||||
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
|
||||
pm.ShortName(), pm.PubKey),
|
||||
), nil
|
||||
case "nevent":
|
||||
event, _, err := sys.FetchSpecificEventFromInput(ctx, uri, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("couldn't find this event anywhere"), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(
|
||||
fmt.Sprintf("this is a Nostr event: %s", event),
|
||||
), nil
|
||||
case "naddr":
|
||||
return mcp.NewToolResultError("for now we can't handle this kind of Nostr uri"), nil
|
||||
default:
|
||||
return mcp.NewToolResultError("we don't know how to handle this Nostr uri"), nil
|
||||
}
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("search_profile",
|
||||
mcp.WithDescription("search for the public key of a Nostr user given their name"),
|
||||
mcp.WithString("name", mcp.Description("name to be searched"), mcp.Required()),
|
||||
mcp.WithNumber("limit", mcp.Description("how many results to return")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
name := required[string](r, "name")
|
||||
limit, _ := optional[float64](r, "limit")
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
res := strings.Builder{}
|
||||
res.Grow(500)
|
||||
res.WriteString("search results: ")
|
||||
|
||||
// check if input is already a valid pubkey
|
||||
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
|
||||
pm := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
} else {
|
||||
// otherwise try to search
|
||||
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
|
||||
if limit > 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
l := 0
|
||||
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
pm, _ := sdk.ParseMetadata(result.Event)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
|
||||
}
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(res.String()), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
|
||||
mcp.WithDescription("get the best relay from where to read notes from a specific Nostr user"),
|
||||
mcp.WithString("pubkey", mcp.Description("public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
res := sys.FetchOutboxRelays(ctx, pubkey, 1)
|
||||
return mcp.NewToolResultText(res[0]), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("read_events_from_relay",
|
||||
mcp.WithDescription("makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
|
||||
mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
|
||||
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
|
||||
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
|
||||
mcp.WithString("pubkey", mcp.Description("pubkey to include in the 'authors' field, if this is not given we will read any events from this relay")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
relay := required[string](r, "relay")
|
||||
kind := int(required[float64](r, "kind"))
|
||||
limit := int(required[float64](r, "limit"))
|
||||
pubkey, hasPubKey := optional[string](r, "pubkey")
|
||||
|
||||
filter := nostr.Filter{
|
||||
Limit: limit,
|
||||
Kinds: []nostr.Kind{nostr.Kind(kind)},
|
||||
}
|
||||
|
||||
if hasPubKey {
|
||||
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
|
||||
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
|
||||
} else {
|
||||
filter.Authors = append(filter.Authors, pk)
|
||||
}
|
||||
}
|
||||
|
||||
events := sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-profile-events",
|
||||
})
|
||||
|
||||
result := strings.Builder{}
|
||||
for ie := range events {
|
||||
result.WriteString("author public key: ")
|
||||
result.WriteString(ie.PubKey.Hex())
|
||||
result.WriteString("\ncontent: '")
|
||||
result.WriteString(ie.Content)
|
||||
result.WriteString("'")
|
||||
result.WriteString("\n---\n")
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(result.String()), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("search_events",
|
||||
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
|
||||
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
|
||||
mcp.WithString("author", mcp.Description("author public key to filter by")),
|
||||
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
|
||||
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
search := required[string](r, "search")
|
||||
author, hasAuthor := optional[string](r, "author")
|
||||
kind, hasKind := optional[float64](r, "kind")
|
||||
limit, _ := optional[float64](r, "limit")
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
filter := nostr.Filter{Search: search, Limit: int(limit)}
|
||||
if hasKind {
|
||||
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
|
||||
}
|
||||
|
||||
var relays []string
|
||||
|
||||
if hasAuthor {
|
||||
if pk, err := nostr.PubKeyFromHex(author); err != nil {
|
||||
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
|
||||
} else {
|
||||
filter.Authors = append(filter.Authors, pk)
|
||||
}
|
||||
|
||||
pk, _ := nostr.PubKeyFromHex(author)
|
||||
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
|
||||
|
||||
for _, relayURL := range writeRelays {
|
||||
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
|
||||
for _, nip := range info.SupportedNIPs {
|
||||
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
|
||||
relays = append(relays, relayURL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(relays) == 0 {
|
||||
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
|
||||
}
|
||||
|
||||
result := strings.Builder{}
|
||||
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
|
||||
|
||||
l := 0
|
||||
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
|
||||
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("no events found matching the search criteria."), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(result.String()), nil
|
||||
})
|
||||
|
||||
return server.ServeStdio(s)
|
||||
},
|
||||
}
|
||||
|
||||
func required[T comparable](r mcp.CallToolRequest, p string) T {
|
||||
var zero T
|
||||
if _, ok := r.Params.Arguments[p]; !ok {
|
||||
return zero
|
||||
}
|
||||
if _, ok := r.Params.Arguments[p].(T); !ok {
|
||||
return zero
|
||||
}
|
||||
if r.Params.Arguments[p].(T) == zero {
|
||||
return zero
|
||||
}
|
||||
return r.Params.Arguments[p].(T)
|
||||
}
|
||||
|
||||
func optional[T any](r mcp.CallToolRequest, p string) (T, bool) {
|
||||
var zero T
|
||||
if _, ok := r.Params.Arguments[p]; !ok {
|
||||
return zero, false
|
||||
}
|
||||
if _, ok := r.Params.Arguments[p].(T); !ok {
|
||||
return zero, false
|
||||
}
|
||||
return r.Params.Arguments[p].(T), true
|
||||
}
|
||||
36
musig2.go
36
musig2.go
@@ -9,39 +9,39 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func getMusigAggregatedKey(_ context.Context, keys []string) (string, error) {
|
||||
func getMusigAggregatedKey(_ context.Context, keys []string) (nostr.PubKey, error) {
|
||||
knownSigners := make([]*btcec.PublicKey, len(keys))
|
||||
for i, spk := range keys {
|
||||
bpk, err := hex.DecodeString(spk)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("'%s' is invalid hex: %w", spk, err)
|
||||
return nostr.ZeroPK, fmt.Errorf("'%s' is invalid hex: %w", spk, err)
|
||||
}
|
||||
if len(bpk) == 32 {
|
||||
return "", fmt.Errorf("'%s' is missing the leading parity byte", spk)
|
||||
return nostr.ZeroPK, fmt.Errorf("'%s' is missing the leading parity byte", spk)
|
||||
}
|
||||
pk, err := btcec.ParsePubKey(bpk)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
|
||||
return nostr.ZeroPK, fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
|
||||
}
|
||||
knownSigners[i] = pk
|
||||
}
|
||||
|
||||
aggpk, _, _, err := musig2.AggregateKeys(knownSigners, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("aggregation failed: %w", err)
|
||||
return nostr.ZeroPK, fmt.Errorf("aggregation failed: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(aggpk.FinalKey.SerializeCompressed()[1:]), nil
|
||||
return nostr.PubKey(aggpk.FinalKey.SerializeCompressed()[1:]), nil
|
||||
}
|
||||
|
||||
func performMusig(
|
||||
_ context.Context,
|
||||
sec string,
|
||||
sec nostr.SecretKey,
|
||||
evt *nostr.Event,
|
||||
numSigners int,
|
||||
keys []string,
|
||||
@@ -50,11 +50,7 @@ func performMusig(
|
||||
partialSigs []string,
|
||||
) (signed bool, err error) {
|
||||
// preprocess data received
|
||||
secb, err := hex.DecodeString(sec)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
seck, pubk := btcec.PrivKeyFromBytes(secb)
|
||||
seck, pubk := btcec.PrivKeyFromBytes(sec[:])
|
||||
|
||||
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
|
||||
includesUs := false
|
||||
@@ -146,7 +142,7 @@ func performMusig(
|
||||
if comb, err := mctx.CombinedKey(); err != nil {
|
||||
return false, fmt.Errorf("failed to combine keys (after %d signers): %w", len(knownSigners), err)
|
||||
} else {
|
||||
evt.PubKey = hex.EncodeToString(comb.SerializeCompressed()[1:])
|
||||
evt.PubKey = nostr.PubKey(comb.SerializeCompressed()[1:])
|
||||
evt.ID = evt.GetID()
|
||||
log("combined key: %x\n\n", comb.SerializeCompressed())
|
||||
}
|
||||
@@ -200,11 +196,7 @@ func performMusig(
|
||||
|
||||
// signing phase
|
||||
// we always have to sign, so let's do this
|
||||
id := evt.GetID()
|
||||
hash, _ := hex.DecodeString(id)
|
||||
var msg32 [32]byte
|
||||
copy(msg32[:], hash)
|
||||
partialSig, err := session.Sign(msg32) // this will already include our sig in the bundle
|
||||
partialSig, err := session.Sign(evt.GetID()) // this will already include our sig in the bundle
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to produce partial signature: %w", err)
|
||||
}
|
||||
@@ -225,7 +217,7 @@ func performMusig(
|
||||
}
|
||||
|
||||
// we have the signature
|
||||
evt.Sig = hex.EncodeToString(session.FinalSig().Serialize())
|
||||
evt.Sig = [64]byte(session.FinalSig().Serialize())
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -258,7 +250,7 @@ func eventToCliArgs(evt *nostr.Event) string {
|
||||
b.Grow(100)
|
||||
|
||||
b.WriteString("-k ")
|
||||
b.WriteString(strconv.Itoa(evt.Kind))
|
||||
b.WriteString(strconv.Itoa(int(evt.Kind)))
|
||||
|
||||
b.WriteString(" -ts ")
|
||||
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
|
||||
@@ -269,7 +261,7 @@ func eventToCliArgs(evt *nostr.Event) string {
|
||||
|
||||
for _, tag := range evt.Tags {
|
||||
b.WriteString(" -t '")
|
||||
b.WriteString(tag.Key())
|
||||
b.WriteString(tag[0])
|
||||
if len(tag) > 1 {
|
||||
b.WriteString("=")
|
||||
b.WriteString(tag[1])
|
||||
|
||||
213
nip.go
Normal file
213
nip.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type nipInfo struct {
|
||||
nip, desc, link string
|
||||
}
|
||||
|
||||
var nip = &cli.Command{
|
||||
Name: "nip",
|
||||
Usage: "list NIPs or get the description of a NIP from its number",
|
||||
Description: `lists NIPs, fetches and displays NIP text, or opens a NIP page in the browser.
|
||||
|
||||
examples:
|
||||
nak nip # list all NIPs
|
||||
nak nip 29 # shows nip29 details
|
||||
nak nip open 29 # opens nip29 in browser`,
|
||||
ArgsUsage: "[NIP number]",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "open",
|
||||
Usage: "open the NIP page in the browser",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
reqNum := c.Args().First()
|
||||
if reqNum == "" {
|
||||
return fmt.Errorf("missing NIP number")
|
||||
}
|
||||
|
||||
normalize := func(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = strings.TrimPrefix(s, "nip-")
|
||||
s = strings.TrimLeft(s, "0")
|
||||
if s == "" {
|
||||
s = "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
reqNum = normalize(reqNum)
|
||||
|
||||
foundLink := ""
|
||||
for info := range listnips() {
|
||||
nipNum := normalize(info.nip)
|
||||
if nipNum == reqNum {
|
||||
foundLink = info.link
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundLink == "" {
|
||||
return fmt.Errorf("NIP-%s not found", strings.ToUpper(reqNum))
|
||||
}
|
||||
|
||||
url := "https://github.com/nostr-protocol/nips/blob/master/" + foundLink
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
},
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
reqNum := c.Args().First()
|
||||
if reqNum == "" {
|
||||
// list all NIPs
|
||||
for info := range listnips() {
|
||||
stdout(info.nip + ": " + info.desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
normalize := func(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = strings.TrimPrefix(s, "nip-")
|
||||
s = strings.TrimLeft(s, "0")
|
||||
if s == "" {
|
||||
s = "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
reqNum = normalize(reqNum)
|
||||
|
||||
var foundLink string
|
||||
for info := range listnips() {
|
||||
nipNum := normalize(info.nip)
|
||||
|
||||
if nipNum == reqNum {
|
||||
foundLink = info.link
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundLink == "" {
|
||||
return fmt.Errorf("NIP-%s not found", strings.ToUpper(reqNum))
|
||||
}
|
||||
|
||||
// fetch the NIP markdown
|
||||
url := "https://raw.githubusercontent.com/nostr-protocol/nips/master/" + foundLink
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch NIP: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read NIP: %w", err)
|
||||
}
|
||||
|
||||
// render markdown
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width < 80 {
|
||||
width = 120
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create renderer: %w", err)
|
||||
}
|
||||
rendered, err := r.Render(string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render markdown: %w", err)
|
||||
}
|
||||
|
||||
stdout(rendered)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func listnips() <-chan nipInfo {
|
||||
ch := make(chan nipInfo)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
resp, err := http.Get("https://raw.githubusercontent.com/nostr-protocol/nips/master/README.md")
|
||||
if err != nil {
|
||||
// TODO: handle error? but since chan, maybe send error somehow, but for now, just close
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bodyStr := string(body)
|
||||
epoch := strings.Index(bodyStr, "## List")
|
||||
if epoch == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
lines := strings.SplitSeq(bodyStr[epoch+8:], "\n")
|
||||
for line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "##") {
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(line, "- [NIP-") {
|
||||
continue
|
||||
}
|
||||
|
||||
start := strings.Index(line, "[")
|
||||
end := strings.Index(line, "]")
|
||||
if start == -1 || end == -1 || end < start {
|
||||
continue
|
||||
}
|
||||
|
||||
content := line[start+1 : end]
|
||||
|
||||
parts := strings.SplitN(content, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
nipPart := parts[0]
|
||||
descPart := parts[1]
|
||||
|
||||
rest := line[end+1:]
|
||||
linkStart := strings.Index(rest, "(")
|
||||
linkEnd := strings.Index(rest, ")")
|
||||
link := ""
|
||||
if linkStart != -1 && linkEnd != -1 && linkEnd > linkStart {
|
||||
link = rest[linkStart+1 : linkEnd]
|
||||
}
|
||||
|
||||
ch <- nipInfo{nipPart, strings.TrimSpace(descPart), link}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
11
non_lmdb.go
Normal file
11
non_lmdb.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux || riscv64 || arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
}
|
||||
56
nostrfs/asyncfile.go
Normal file
56
nostrfs/asyncfile.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type AsyncFile struct {
|
||||
fs.Inode
|
||||
ctx context.Context
|
||||
fetched atomic.Bool
|
||||
data []byte
|
||||
ts nostr.Timestamp
|
||||
load func() ([]byte, nostr.Timestamp)
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpener)((*AsyncFile)(nil))
|
||||
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
|
||||
)
|
||||
|
||||
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
if af.fetched.CompareAndSwap(false, true) {
|
||||
af.data, af.ts = af.load()
|
||||
}
|
||||
|
||||
out.Size = uint64(len(af.data))
|
||||
out.Mtime = uint64(af.ts)
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
||||
if af.fetched.CompareAndSwap(false, true) {
|
||||
af.data, af.ts = af.load()
|
||||
}
|
||||
|
||||
return nil, fuse.FOPEN_KEEP_CACHE, 0
|
||||
}
|
||||
|
||||
func (af *AsyncFile) Read(
|
||||
ctx context.Context,
|
||||
f fs.FileHandle,
|
||||
dest []byte,
|
||||
off int64,
|
||||
) (fuse.ReadResult, syscall.Errno) {
|
||||
end := int(off) + len(dest)
|
||||
if end > len(af.data) {
|
||||
end = len(af.data)
|
||||
}
|
||||
return fuse.ReadResultData(af.data[off:end]), 0
|
||||
}
|
||||
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
|
||||
}
|
||||
408
nostrfs/entitydir.go
Normal file
408
nostrfs/entitydir.go
Normal file
@@ -0,0 +1,408 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/lib/debouncer"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip27"
|
||||
"fiatjaf.com/nostr/nip73"
|
||||
"fiatjaf.com/nostr/nip92"
|
||||
sdk "fiatjaf.com/nostr/sdk"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type EntityDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
|
||||
publisher *debouncer.Debouncer
|
||||
event *nostr.Event
|
||||
updating struct {
|
||||
title string
|
||||
content string
|
||||
publishedAt uint64
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
|
||||
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
|
||||
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
|
||||
_ = (fs.NodeCreater)((*EntityDir)(nil))
|
||||
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
|
||||
)
|
||||
|
||||
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Ctime = uint64(e.event.CreatedAt)
|
||||
if e.updating.publishedAt != 0 {
|
||||
out.Mtime = e.updating.publishedAt
|
||||
} else {
|
||||
out.Mtime = e.PublishedAt()
|
||||
}
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (e *EntityDir) Create(
|
||||
_ context.Context,
|
||||
name string,
|
||||
flags uint32,
|
||||
mode uint32,
|
||||
out *fuse.EntryOut,
|
||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if name == "publish" && e.publisher.IsRunning() {
|
||||
// this causes the publish process to be triggered faster
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing now!\n")
|
||||
e.publisher.Flush()
|
||||
return nil, nil, 0, syscall.ENOTDIR
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
switch name {
|
||||
case "content" + kindToExtension(e.event.Kind):
|
||||
e.updating.content = e.event.Content
|
||||
return syscall.ENOTDIR
|
||||
case "title":
|
||||
e.updating.title = e.Title()
|
||||
return syscall.ENOTDIR
|
||||
default:
|
||||
return syscall.EINTR
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||
e.updating.publishedAt = in.Mtime
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (e *EntityDir) OnAdd(_ context.Context) {
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
e.AddChild("@author", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
|
||||
e.AddChild("event.json", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
eventj, _ := json.MarshalIndent(e.event, "", " ")
|
||||
return uint64(e.event.CreatedAt),
|
||||
uint64(e.event.CreatedAt),
|
||||
unsafe.String(unsafe.SliceData(eventj), len(eventj))
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
e.AddChild("identifier", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(e.event.Tags.GetD()),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(e.event.CreatedAt),
|
||||
Mtime: uint64(e.event.CreatedAt),
|
||||
Size: uint64(len(e.event.Tags.GetD())),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
|
||||
// read-only
|
||||
e.AddChild("title", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
} else {
|
||||
// writeable
|
||||
e.updating.title = e.Title()
|
||||
e.updating.publishedAt = e.PublishedAt()
|
||||
e.updating.content = e.event.Content
|
||||
|
||||
e.AddChild("title", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||
log("title updated")
|
||||
e.updating.title = strings.TrimSpace(s)
|
||||
e.handleWrite()
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||
log("content updated")
|
||||
e.updating.content = strings.TrimSpace(s)
|
||||
e.handleWrite()
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
var refsdir *fs.Inode
|
||||
i := 0
|
||||
for ref := range nip27.Parse(e.event.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
if refsdir == nil {
|
||||
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
e.root.AddChild("references", refsdir, true)
|
||||
}
|
||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
|
||||
var imagesdir *fs.Inode
|
||||
addImage := func(url string) {
|
||||
if imagesdir == nil {
|
||||
in := &fs.Inode{}
|
||||
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
e.AddChild("images", imagesdir, true)
|
||||
}
|
||||
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&AsyncFile{
|
||||
ctx: e.root.ctx,
|
||||
load: func() ([]byte, nostr.Timestamp) {
|
||||
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
w := &bytes.Buffer{}
|
||||
io.Copy(w, resp.Body)
|
||||
return w.Bytes(), 0
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
images := nip92.ParseTags(e.event.Tags)
|
||||
for _, imeta := range images {
|
||||
if imeta.URL == "" {
|
||||
continue
|
||||
}
|
||||
addImage(imeta.URL)
|
||||
}
|
||||
|
||||
if tag := e.event.Tags.Find("image"); tag != nil {
|
||||
addImage(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EntityDir) IsNew() bool {
|
||||
return e.event.CreatedAt == 0
|
||||
}
|
||||
|
||||
func (e *EntityDir) PublishedAt() uint64 {
|
||||
if tag := e.event.Tags.Find("published_at"); tag != nil {
|
||||
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
|
||||
return publishedAt
|
||||
}
|
||||
return uint64(e.event.CreatedAt)
|
||||
}
|
||||
|
||||
func (e *EntityDir) Title() string {
|
||||
if tag := e.event.Tags.Find("title"); tag != nil {
|
||||
return tag[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *EntityDir) handleWrite() {
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
|
||||
|
||||
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
|
||||
if e.publisher.IsRunning() {
|
||||
log(", timer reset")
|
||||
}
|
||||
log(", publishing the ")
|
||||
if e.IsNew() {
|
||||
log("new")
|
||||
} else {
|
||||
log("updated")
|
||||
}
|
||||
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
|
||||
} else {
|
||||
log(".\n")
|
||||
}
|
||||
if !e.publisher.IsRunning() {
|
||||
log("- `touch publish` to publish immediately\n")
|
||||
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
|
||||
}
|
||||
|
||||
e.publisher.Call(func() {
|
||||
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
|
||||
log("not modified, publish canceled.\n")
|
||||
return
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: e.event.Kind,
|
||||
Content: e.updating.content,
|
||||
Tags: make(nostr.Tags, len(e.event.Tags)),
|
||||
CreatedAt: nostr.Now(),
|
||||
}
|
||||
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
|
||||
if e.updating.title != "" {
|
||||
if titleTag := evt.Tags.Find("title"); titleTag != nil {
|
||||
titleTag[1] = e.updating.title
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
|
||||
}
|
||||
}
|
||||
|
||||
// "published_at" tag
|
||||
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
|
||||
if publishedAtStr != "0" {
|
||||
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
|
||||
publishedAtTag[1] = publishedAtStr
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
|
||||
}
|
||||
}
|
||||
|
||||
// add "p" tags from people mentioned and "q" tags from events mentioned
|
||||
for ref := range nip27.Parse(evt.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := ref.Pointer.AsTag()
|
||||
key := tag[0]
|
||||
val := tag[1]
|
||||
if key == "e" || key == "a" {
|
||||
key = "q"
|
||||
}
|
||||
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// sign and publish
|
||||
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
|
||||
log("failed to sign: '%s'.\n", err)
|
||||
return
|
||||
}
|
||||
logverbose("%s\n", evt)
|
||||
|
||||
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
|
||||
if len(relays) == 0 {
|
||||
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
|
||||
}
|
||||
|
||||
log("publishing to %d relays... ", len(relays))
|
||||
success := false
|
||||
first := true
|
||||
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||
} else {
|
||||
success = true
|
||||
log("%s: ok", color.GreenString(cleanUrl))
|
||||
}
|
||||
}
|
||||
log("\n")
|
||||
|
||||
if success {
|
||||
e.event = &evt
|
||||
log("event updated locally.\n")
|
||||
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
|
||||
} else {
|
||||
log("failed.\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (r *NostrRoot) FetchAndCreateEntityDir(
|
||||
parent fs.InodeEmbedder,
|
||||
extension string,
|
||||
pointer nostr.EntityPointer,
|
||||
) (*fs.Inode, error) {
|
||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
|
||||
return r.CreateEntityDir(parent, event), nil
|
||||
}
|
||||
|
||||
func (r *NostrRoot) CreateEntityDir(
|
||||
parent fs.InodeEmbedder,
|
||||
event *nostr.Event,
|
||||
) *fs.Inode {
|
||||
return parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
)
|
||||
}
|
||||
241
nostrfs/eventdir.go
Normal file
241
nostrfs/eventdir.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip10"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip22"
|
||||
"fiatjaf.com/nostr/nip27"
|
||||
"fiatjaf.com/nostr/nip73"
|
||||
"fiatjaf.com/nostr/nip92"
|
||||
sdk "fiatjaf.com/nostr/sdk"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type EventDir struct {
|
||||
fs.Inode
|
||||
ctx context.Context
|
||||
wd string
|
||||
evt *nostr.Event
|
||||
}
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
|
||||
|
||||
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Mtime = uint64(e.evt.CreatedAt)
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (r *NostrRoot) FetchAndCreateEventDir(
|
||||
parent fs.InodeEmbedder,
|
||||
pointer nostr.EventPointer,
|
||||
) (*fs.Inode, error) {
|
||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
|
||||
return r.CreateEventDir(parent, event), nil
|
||||
}
|
||||
|
||||
func (r *NostrRoot) CreateEventDir(
|
||||
parent fs.InodeEmbedder,
|
||||
event *nostr.Event,
|
||||
) *fs.Inode {
|
||||
h := parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
|
||||
)
|
||||
|
||||
h.AddChild("@author", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
|
||||
eventj, _ := json.MarshalIndent(event, "", " ")
|
||||
h.AddChild("event.json", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: eventj,
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(len(event.Content)),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
h.AddChild("id", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(event.ID.Hex()),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(64),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
h.AddChild("content.txt", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(event.Content),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(len(event.Content)),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
var refsdir *fs.Inode
|
||||
i := 0
|
||||
for ref := range nip27.Parse(event.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
if refsdir == nil {
|
||||
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
h.AddChild("references", refsdir, true)
|
||||
}
|
||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
|
||||
var imagesdir *fs.Inode
|
||||
images := nip92.ParseTags(event.Tags)
|
||||
for _, imeta := range images {
|
||||
if imeta.URL == "" {
|
||||
continue
|
||||
}
|
||||
if imagesdir == nil {
|
||||
in := &fs.Inode{}
|
||||
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
h.AddChild("images", imagesdir, true)
|
||||
}
|
||||
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
|
||||
r.ctx,
|
||||
&AsyncFile{
|
||||
ctx: r.ctx,
|
||||
load: func() ([]byte, nostr.Timestamp) {
|
||||
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, 0
|
||||
}
|
||||
w := &bytes.Buffer{}
|
||||
io.Copy(w, resp.Body)
|
||||
return w.Bytes(), 0
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
if event.Kind == 1 {
|
||||
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@root", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@parent", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
} else if event.Kind == 1111 {
|
||||
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
|
||||
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||
h.AddChild("@root", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
} else {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@parent", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
}
|
||||
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
|
||||
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||
h.AddChild("@parent", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
} else {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@parent", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
16
nostrfs/helpers.go
Normal file
16
nostrfs/helpers.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"fiatjaf.com/nostr"
|
||||
)
|
||||
|
||||
func kindToExtension(kind nostr.Kind) string {
|
||||
switch kind {
|
||||
case 30023:
|
||||
return "md"
|
||||
case 30818:
|
||||
return "adoc"
|
||||
default:
|
||||
return "txt"
|
||||
}
|
||||
}
|
||||
261
nostrfs/npubdir.go
Normal file
261
nostrfs/npubdir.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/liamg/magic"
|
||||
)
|
||||
|
||||
type NpubDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
pointer nostr.ProfilePointer
|
||||
fetched atomic.Bool
|
||||
}
|
||||
|
||||
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
|
||||
|
||||
func (r *NostrRoot) CreateNpubDir(
|
||||
parent fs.InodeEmbedder,
|
||||
pointer nostr.ProfilePointer,
|
||||
signer nostr.Signer,
|
||||
) *fs.Inode {
|
||||
npubdir := &NpubDir{root: r, pointer: pointer}
|
||||
return parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
npubdir,
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
|
||||
)
|
||||
}
|
||||
|
||||
func (h *NpubDir) OnAdd(_ context.Context) {
|
||||
log := h.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
|
||||
log("- adding folder for %s with relays %s\n",
|
||||
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
|
||||
|
||||
h.AddChild("pubkey", h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
go func() {
|
||||
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
|
||||
if pm.Event == nil {
|
||||
return
|
||||
}
|
||||
|
||||
metadataj, _ := json.MarshalIndent(pm, "", " ")
|
||||
h.AddChild(
|
||||
"metadata.json",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: metadataj,
|
||||
Attr: fuse.Attr{
|
||||
Mtime: uint64(pm.Event.CreatedAt),
|
||||
Mode: 0444,
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
|
||||
if err == nil {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
b := &bytes.Buffer{}
|
||||
io.Copy(b, resp.Body)
|
||||
|
||||
ext := "png"
|
||||
if ft, err := magic.Lookup(b.Bytes()); err == nil {
|
||||
ext = ft.Extension
|
||||
}
|
||||
|
||||
h.AddChild("picture."+ext, h.NewPersistentInode(
|
||||
ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: b.Bytes(),
|
||||
Attr: fuse.Attr{
|
||||
Mtime: uint64(pm.Event.CreatedAt),
|
||||
Mode: 0444,
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if h.GetChild("notes") == nil {
|
||||
h.AddChild(
|
||||
"notes",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{1},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("comments") == nil {
|
||||
h.AddChild(
|
||||
"comments",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("photos") == nil {
|
||||
h.AddChild(
|
||||
"photos",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{20},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("videos") == nil {
|
||||
h.AddChild(
|
||||
"videos",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{21, 22},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("highlights") == nil {
|
||||
h.AddChild(
|
||||
"highlights",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9802},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("articles") == nil {
|
||||
h.AddChild(
|
||||
"articles",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{30023},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: true,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("wiki") == nil {
|
||||
h.AddChild(
|
||||
"wiki",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{30818},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: true,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
130
nostrfs/root.go
Normal file
130
nostrfs/root.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
AutoPublishNotesTimeout time.Duration
|
||||
AutoPublishArticlesTimeout time.Duration
|
||||
}
|
||||
|
||||
type NostrRoot struct {
|
||||
fs.Inode
|
||||
|
||||
ctx context.Context
|
||||
wd string
|
||||
sys *sdk.System
|
||||
rootPubKey nostr.PubKey
|
||||
signer nostr.Signer
|
||||
|
||||
opts Options
|
||||
}
|
||||
|
||||
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
|
||||
|
||||
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
|
||||
pubkey, _ := user.GetPublicKey(ctx)
|
||||
abs, _ := filepath.Abs(mountpoint)
|
||||
|
||||
var signer nostr.Signer
|
||||
if user != nil {
|
||||
signer, _ = user.(nostr.Signer)
|
||||
}
|
||||
|
||||
return &NostrRoot{
|
||||
ctx: ctx,
|
||||
sys: sys,
|
||||
rootPubKey: pubkey,
|
||||
signer: signer,
|
||||
wd: abs,
|
||||
|
||||
opts: o,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *NostrRoot) OnAdd(_ context.Context) {
|
||||
if r.rootPubKey == nostr.ZeroPK {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// add our contacts
|
||||
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
|
||||
for _, f := range fl.Items {
|
||||
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
|
||||
r.AddChild(
|
||||
nip19.EncodeNpub(f.Pubkey),
|
||||
r.CreateNpubDir(r, pointer, nil),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// add ourselves
|
||||
npub := nip19.EncodeNpub(r.rootPubKey)
|
||||
if r.GetChild(npub) == nil {
|
||||
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
|
||||
|
||||
r.AddChild(
|
||||
npub,
|
||||
r.CreateNpubDir(r, pointer, r.signer),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// add a link to ourselves
|
||||
r.AddChild("@me", r.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
out.SetEntryTimeout(time.Minute * 5)
|
||||
|
||||
child := r.GetChild(name)
|
||||
if child != nil {
|
||||
return child, fs.OK
|
||||
}
|
||||
|
||||
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
|
||||
return r.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), fs.OK
|
||||
}
|
||||
|
||||
pointer, err := nip19.ToPointer(name)
|
||||
if err != nil {
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
|
||||
switch p := pointer.(type) {
|
||||
case nostr.ProfilePointer:
|
||||
npubdir := r.CreateNpubDir(r, p, nil)
|
||||
return npubdir, fs.OK
|
||||
case nostr.EventPointer:
|
||||
eventdir, err := r.FetchAndCreateEventDir(r, p)
|
||||
if err != nil {
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
return eventdir, fs.OK
|
||||
default:
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
}
|
||||
267
nostrfs/viewdir.go
Normal file
267
nostrfs/viewdir.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"fiatjaf.com/lib/debouncer"
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type ViewDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
fetched atomic.Bool
|
||||
filter nostr.Filter
|
||||
paginate bool
|
||||
relays []string
|
||||
replaceable bool
|
||||
createable bool
|
||||
publisher *debouncer.Debouncer
|
||||
publishing struct {
|
||||
note string
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeCreater)((*ViewDir)(nil))
|
||||
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
|
||||
)
|
||||
|
||||
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Create(
|
||||
_ context.Context,
|
||||
name string,
|
||||
flags uint32,
|
||||
mode uint32,
|
||||
out *fuse.EntryOut,
|
||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return nil, nil, 0, syscall.EPERM
|
||||
}
|
||||
if n.publisher == nil {
|
||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||
}
|
||||
if n.filter.Kinds[0] != 1 {
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "new":
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
if n.publisher.IsRunning() {
|
||||
log("pending note updated, timer reset.")
|
||||
} else {
|
||||
log("new note detected")
|
||||
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
|
||||
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
|
||||
} else {
|
||||
log(".\n")
|
||||
}
|
||||
log("- `touch publish` to publish immediately\n")
|
||||
log("- `rm new` to erase and cancel the publication.\n")
|
||||
}
|
||||
|
||||
n.publisher.Call(n.publishNote)
|
||||
|
||||
first := true
|
||||
|
||||
return n.NewPersistentInode(
|
||||
n.root.ctx,
|
||||
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
|
||||
if !first {
|
||||
log("pending note updated, timer reset.\n")
|
||||
}
|
||||
first = false
|
||||
n.publishing.note = strings.TrimSpace(s)
|
||||
n.publisher.Call(n.publishNote)
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), nil, 0, fs.OK
|
||||
case "publish":
|
||||
if n.publisher.IsRunning() {
|
||||
// this causes the publish process to be triggered faster
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing now!\n")
|
||||
n.publisher.Flush()
|
||||
return nil, nil, 0, syscall.ENOTDIR
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return syscall.EPERM
|
||||
}
|
||||
if n.publisher == nil {
|
||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||
}
|
||||
if n.filter.Kinds[0] != 1 {
|
||||
return syscall.ENOTSUP
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "new":
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing canceled.\n")
|
||||
n.publisher.Stop()
|
||||
n.publishing.note = ""
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
return syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (n *ViewDir) publishNote() {
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
log("publishing note...\n")
|
||||
evt := nostr.Event{
|
||||
Kind: 1,
|
||||
CreatedAt: nostr.Now(),
|
||||
Content: n.publishing.note,
|
||||
Tags: make(nostr.Tags, 0, 2),
|
||||
}
|
||||
|
||||
// our write relays
|
||||
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
|
||||
if len(relays) == 0 {
|
||||
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
|
||||
}
|
||||
|
||||
// massage and extract tags from raw text
|
||||
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
|
||||
relays = nostr.AppendUnique(relays, targetRelays...)
|
||||
|
||||
// sign and publish
|
||||
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
|
||||
log("failed to sign: %s\n", err)
|
||||
return
|
||||
}
|
||||
log(evt.String() + "\n")
|
||||
|
||||
log("publishing to %d relays... ", len(relays))
|
||||
success := false
|
||||
first := true
|
||||
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||
} else {
|
||||
success = true
|
||||
log("%s: ok", color.GreenString(cleanUrl))
|
||||
}
|
||||
}
|
||||
log("\n")
|
||||
|
||||
if success {
|
||||
n.RmChild("new")
|
||||
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
|
||||
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
now := nostr.Now()
|
||||
if n.filter.Until != 0 {
|
||||
now = n.filter.Until
|
||||
}
|
||||
aMonthAgo := now - 30*24*60*60
|
||||
out.Mtime = uint64(aMonthAgo)
|
||||
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
|
||||
if n.fetched.CompareAndSwap(true, true) {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
if n.paginate {
|
||||
now := nostr.Now()
|
||||
if n.filter.Until != 0 {
|
||||
now = n.filter.Until
|
||||
}
|
||||
aMonthAgo := now - 30*24*60*60
|
||||
n.filter.Since = aMonthAgo
|
||||
|
||||
filter := n.filter
|
||||
filter.Until = aMonthAgo
|
||||
|
||||
n.AddChild("@previous", n.NewPersistentInode(
|
||||
n.root.ctx,
|
||||
&ViewDir{
|
||||
root: n.root,
|
||||
filter: filter,
|
||||
relays: n.relays,
|
||||
replaceable: n.replaceable,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
), true)
|
||||
}
|
||||
|
||||
if n.replaceable {
|
||||
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
|
||||
Label: "nakfs",
|
||||
}).Range {
|
||||
name := rkey.D
|
||||
if name == "" {
|
||||
name = "_"
|
||||
}
|
||||
if n.GetChild(name) == nil {
|
||||
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
|
||||
nostr.SubscriptionOptions{
|
||||
Label: "nakfs",
|
||||
}) {
|
||||
if n.GetChild(ie.Event.ID.Hex()) == nil {
|
||||
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return nil, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
if n.replaceable {
|
||||
// create a template event that can later be modified and published as new
|
||||
return n.root.CreateEntityDir(n, &nostr.Event{
|
||||
PubKey: n.root.rootPubKey,
|
||||
CreatedAt: 0,
|
||||
Kind: n.filter.Kinds[0],
|
||||
Tags: nostr.Tags{
|
||||
nostr.Tag{"d", name},
|
||||
},
|
||||
}), fs.OK
|
||||
}
|
||||
|
||||
return nil, syscall.ENOTSUP
|
||||
}
|
||||
93
nostrfs/writeablefile.go
Normal file
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
|
||||
}
|
||||
1180
nostrfs_cgo/root.go
Normal file
1180
nostrfs_cgo/root.go
Normal file
File diff suppressed because it is too large
Load Diff
338
nsite.go
Normal file
338
nsite.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip5a"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var nsite = &cli.Command{
|
||||
Name: "nsite",
|
||||
Suggest: true,
|
||||
Usage: "publishes and downloads nip-5A static sites",
|
||||
ArgsUsage: "<directory> [relay...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{}),
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "upload",
|
||||
Usage: "uploads site files and publishes manifest event",
|
||||
ArgsUsage: "<directory> [relay...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "append given relays to user's outbox relays instead of using them exclusively",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "root",
|
||||
Usage: "publish root site as kind 15128",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "publish named site as kind 35128 with this d tag",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "title",
|
||||
Usage: "a human-readable title for the site",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "description",
|
||||
Usage: "a human-readable description of the site",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Usage: "a link to the source code of the site",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "blossom server hostname or URL, can be given multiple times",
|
||||
DefaultText: "defaults to the publisher's list of preferred blossom servers",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "inject extra tag into the manifest event, e.g. -t action=profile",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "yes",
|
||||
Aliases: []string{"y"},
|
||||
Usage: "skip upload confirmation prompt",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
dir := c.Args().First()
|
||||
if dir == "" {
|
||||
return fmt.Errorf("missing directory")
|
||||
}
|
||||
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat %s: %w", dir, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
|
||||
root := c.Bool("root")
|
||||
identifier := c.String("identifier")
|
||||
if root == (identifier != "") {
|
||||
return fmt.Errorf("pick exactly one of --root or --identifier/-d")
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
manifest := nip5a.SiteManifest{
|
||||
Pubkey: pk,
|
||||
Root: root,
|
||||
Identifier: identifier,
|
||||
Paths: make(map[string][32]byte),
|
||||
Title: c.String("title"),
|
||||
Description: c.String("description"),
|
||||
Source: c.String("source"),
|
||||
}
|
||||
|
||||
blossomServers := c.StringSlice("server")
|
||||
if len(blossomServers) != 0 {
|
||||
manifest.Servers = blossomServers
|
||||
} else {
|
||||
servers := sys.FetchBlossomServerList(ctx, pk)
|
||||
if len(servers.Items) == 0 {
|
||||
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
|
||||
}
|
||||
blossomServers = make([]string, len(servers.Items))
|
||||
for i, s := range servers.Items {
|
||||
blossomServers[i] = s.Value()
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Bool("yes") {
|
||||
log("%s\n", color.CyanString("files:"))
|
||||
|
||||
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
log(" %s\n", color.GreenString("/%s", filepath.ToSlash(relPath)))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log("%s\n", color.CyanString("blossom servers:"))
|
||||
for _, server := range blossomServers {
|
||||
log(" %s\n", color.YellowString(server))
|
||||
}
|
||||
if !askConfirmation("upload nsite and publish manifest? [y/n] ") {
|
||||
return fmt.Errorf("aborted")
|
||||
}
|
||||
}
|
||||
|
||||
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
var hhash string
|
||||
for _, server := range blossomServers {
|
||||
client := blossom.NewClient(server, kr)
|
||||
bd, err := client.UploadFilePath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload %s to %s: %w", path, server, err)
|
||||
}
|
||||
hhash = bd.SHA256
|
||||
log("uploaded %s to %s as %s\n", color.GreenString(path), color.YellowString(server), color.CyanString(hhash))
|
||||
}
|
||||
|
||||
var hash [32]byte
|
||||
if _, err := hex.Decode(hash[:], []byte(hhash)); err != nil {
|
||||
return fmt.Errorf("invalid blob hash '%s': %w", hhash, err)
|
||||
}
|
||||
manifest.Paths["/"+filepath.ToSlash(relPath)] = hash
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt := manifest.ToEvent()
|
||||
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
tagName, tagValue, found := strings.Cut(tagFlag, "=")
|
||||
tag := []string{tagName}
|
||||
if found {
|
||||
tag = append(tag, strings.Split(tagValue, ";")...)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("error signing manifest event: %w", err)
|
||||
}
|
||||
|
||||
givenRelays := c.Args().Slice()[1:]
|
||||
var relayURLs []string
|
||||
if len(givenRelays) == 0 {
|
||||
relayURLs = sys.FetchWriteRelays(ctx, pk)
|
||||
} else if c.Bool("outbox") {
|
||||
relayURLs = nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), givenRelays...)
|
||||
} else {
|
||||
relayURLs = givenRelays
|
||||
}
|
||||
if len(relayURLs) == 0 {
|
||||
return fmt.Errorf("no relays to publish this nsite to")
|
||||
}
|
||||
|
||||
relays := connectToAllRelays(ctx, c, relayURLs)
|
||||
if len(relays) == 0 {
|
||||
return fmt.Errorf("failed to connect to any of [ %v ]", relayURLs)
|
||||
}
|
||||
|
||||
stdout(evt.String())
|
||||
if identifier == "" {
|
||||
log(nip19.EncodeNpub(pk) + "\n")
|
||||
} else {
|
||||
log(nip5a.PubKeyToBase36(pk) + identifier + "\n")
|
||||
}
|
||||
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Usage: "downloads all files from a published nsite",
|
||||
ArgsUsage: "<site> [directory]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
input := c.Args().First()
|
||||
if input == "" {
|
||||
return fmt.Errorf("missing site")
|
||||
}
|
||||
|
||||
outputDir := c.Args().Get(1)
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("missing write directory")
|
||||
}
|
||||
if st, err := os.Stat(outputDir); err == nil {
|
||||
if st.IsDir() {
|
||||
return fmt.Errorf("output directory %s already exists", outputDir)
|
||||
}
|
||||
return fmt.Errorf("output path %s already exists and is not a directory", outputDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to stat output directory %s: %w", outputDir, err)
|
||||
}
|
||||
|
||||
pk, identifier, isRoot, err := nip5a.DecodeSiteURL(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := nostr.Filter{
|
||||
Authors: []nostr.PubKey{pk},
|
||||
Limit: 1,
|
||||
}
|
||||
if isRoot {
|
||||
filter.Kinds = []nostr.Kind{nostr.KindNsiteRoot}
|
||||
} else {
|
||||
filter.Kinds = []nostr.Kind{nostr.KindNsiteNamed}
|
||||
filter.Tags = nostr.TagMap{"d": []string{identifier}}
|
||||
}
|
||||
|
||||
res := sys.Pool.QuerySingle(ctx, sys.FetchWriteRelays(ctx, pk), filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-nsite",
|
||||
})
|
||||
if res == nil {
|
||||
return fmt.Errorf("failed to fetch nsite with filter %v", filter)
|
||||
}
|
||||
|
||||
mnf, err := nip5a.ParseSiteManifest(&res.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid nsite %s: %w", res.Event, err)
|
||||
}
|
||||
|
||||
blossomServers := mnf.Servers
|
||||
if len(blossomServers) == 0 {
|
||||
servers := sys.FetchBlossomServerList(ctx, res.Event.PubKey)
|
||||
if len(servers.Items) == 0 {
|
||||
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
|
||||
}
|
||||
blossomServers = make([]string, len(servers.Items))
|
||||
for i, s := range servers.Items {
|
||||
blossomServers[i] = s.Value()
|
||||
}
|
||||
}
|
||||
|
||||
signer := keyer.NewReadOnlySigner(pk)
|
||||
|
||||
for path, hash := range mnf.Paths {
|
||||
relPath := strings.TrimPrefix(path, "/")
|
||||
if !filepath.IsLocal(relPath) {
|
||||
return fmt.Errorf("manifest path %q escapes output directory", path)
|
||||
}
|
||||
fullPath := filepath.Join(outputDir, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(fullPath), err)
|
||||
}
|
||||
|
||||
var downloadErr error
|
||||
for _, server := range blossomServers {
|
||||
client := blossom.NewClient(server, signer)
|
||||
data, err := client.Download(ctx, hash)
|
||||
if err != nil {
|
||||
downloadErr = err
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", fullPath, err)
|
||||
}
|
||||
stdout(path)
|
||||
downloadErr = nil
|
||||
break
|
||||
}
|
||||
if downloadErr != nil {
|
||||
return fmt.Errorf("failed to download '%s': %w", path, downloadErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
38
outbox.go
Normal file
38
outbox.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var outboxCmd = &cli.Command{
|
||||
Name: "outbox",
|
||||
Usage: "manage outbox relay hints database",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "list outbox relays for a given pubkey",
|
||||
ArgsUsage: "<pubkey>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("expected exactly one argument (pubkey)")
|
||||
}
|
||||
|
||||
pk, err := parsePubKey(c.Args().First())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
|
||||
}
|
||||
|
||||
for _, relay := range sys.FetchOutboxRelays(ctx, pk, 6) {
|
||||
stdout(relay)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
76
paginate.go
76
paginate.go
@@ -1,76 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func paginateWithParams(
|
||||
interval time.Duration,
|
||||
globalLimit uint64,
|
||||
) func(ctx context.Context, urls []string, filters nostr.Filters, opts ...nostr.SubscriptionOption) chan nostr.RelayEvent {
|
||||
return func(ctx context.Context, urls []string, filters nostr.Filters, opts ...nostr.SubscriptionOption) chan nostr.RelayEvent {
|
||||
// filters will always be just one
|
||||
filter := filters[0]
|
||||
|
||||
nextUntil := nostr.Now()
|
||||
if filter.Until != nil {
|
||||
nextUntil = *filter.Until
|
||||
}
|
||||
|
||||
if globalLimit == 0 {
|
||||
globalLimit = uint64(filter.Limit)
|
||||
if globalLimit == 0 && !filter.LimitZero {
|
||||
globalLimit = math.MaxUint64
|
||||
}
|
||||
}
|
||||
var globalCount uint64 = 0
|
||||
globalCh := make(chan nostr.RelayEvent)
|
||||
|
||||
repeatedCache := make([]string, 0, 300)
|
||||
nextRepeatedCache := make([]string, 0, 300)
|
||||
|
||||
go func() {
|
||||
defer close(globalCh)
|
||||
|
||||
for {
|
||||
filter.Until = &nextUntil
|
||||
time.Sleep(interval)
|
||||
|
||||
keepGoing := false
|
||||
for evt := range sys.Pool.SubManyEose(ctx, urls, nostr.Filters{filter}, opts...) {
|
||||
if slices.Contains(repeatedCache, evt.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
keepGoing = true // if we get one that isn't repeated, then keep trying to get more
|
||||
nextRepeatedCache = append(nextRepeatedCache, evt.ID)
|
||||
|
||||
globalCh <- evt
|
||||
|
||||
globalCount++
|
||||
if globalCount >= globalLimit {
|
||||
return
|
||||
}
|
||||
|
||||
if evt.CreatedAt < *filter.Until {
|
||||
nextUntil = evt.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
|
||||
repeatedCache = nextRepeatedCache
|
||||
nextRepeatedCache = nextRepeatedCache[:0]
|
||||
}
|
||||
}()
|
||||
|
||||
return globalCh
|
||||
}
|
||||
}
|
||||
625
podcast.go
Normal file
625
podcast.go
Normal file
@@ -0,0 +1,625 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type podcastInfo struct {
|
||||
PubKey nostr.PubKey
|
||||
Title string
|
||||
Image string
|
||||
Relays []string
|
||||
Metadata nostr.Event
|
||||
}
|
||||
|
||||
var podcast = &cli.Command{
|
||||
Name: "podcast",
|
||||
Usage: "play podcasts from Nostr",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "play",
|
||||
Usage: "play latest episode from podcast or specific episode",
|
||||
ArgsUsage: "<pubkey|nip05|npub|nprofile|nevent>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "player",
|
||||
Usage: "player command to use, defaults to auto-detect",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if target == "" {
|
||||
return fmt.Errorf("missing podcast or episode target")
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
episode, err := resolvePodcastEpisode(ctx, target, relays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := playPodcastEpisode(c.String("player"), episode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Usage: "display podcast metadata",
|
||||
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if target == "" {
|
||||
return fmt.Errorf("missing podcast target")
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range podcasts {
|
||||
printPodcastInfo(p)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "list podcast episodes",
|
||||
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "maximum number of episodes to fetch",
|
||||
Value: 50,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
if target == "" {
|
||||
return fmt.Errorf("missing podcast target")
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range podcasts {
|
||||
if err := printPodcastEpisodeList(ctx, p, int(c.Int("limit"))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func resolvePodcastEpisode(ctx context.Context, target string, relayHints []string) (*nostr.Event, error) {
|
||||
target = strings.TrimSpace(strings.TrimPrefix(target, "nostr:"))
|
||||
|
||||
if prefix, value, err := nip19.Decode(target); err == nil {
|
||||
switch prefix {
|
||||
case "nevent":
|
||||
pointer := value.(nostr.EventPointer)
|
||||
return fetchSpecificPodcastEpisode(ctx, pointer, relayHints)
|
||||
case "note":
|
||||
return fetchSpecificPodcastEpisode(ctx, value.(nostr.EventPointer), relayHints)
|
||||
}
|
||||
}
|
||||
|
||||
if pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target); err == nil {
|
||||
return fetchLatestEpisodeForPodcastSelection(ctx, pk, nostr.AppendUnique(relayHints, extraRelays...))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid podcast target %q", target)
|
||||
}
|
||||
|
||||
func resolvePodcastPubKeyTarget(ctx context.Context, target string) (nostr.PubKey, []string, error) {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return nostr.ZeroPK, nil, fmt.Errorf("missing pubkey target")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(target, "nostr:") {
|
||||
target = strings.TrimPrefix(target, "nostr:")
|
||||
}
|
||||
|
||||
if prefix, value, err := nip19.Decode(target); err == nil {
|
||||
switch prefix {
|
||||
case "npub":
|
||||
return value.(nostr.PubKey), nil, nil
|
||||
case "nprofile":
|
||||
profile := value.(nostr.ProfilePointer)
|
||||
return profile.PublicKey, profile.Relays, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(target, "@") {
|
||||
pp, err := nip05.QueryIdentifier(ctx, target)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, nil, err
|
||||
}
|
||||
return pp.PublicKey, pp.Relays, nil
|
||||
}
|
||||
|
||||
pk, err := nostr.PubKeyFromHex(target)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, nil, err
|
||||
}
|
||||
return pk, nil, nil
|
||||
}
|
||||
|
||||
func fetchSpecificPodcastEpisode(ctx context.Context, pointer nostr.EventPointer, relayHints []string) (*nostr.Event, error) {
|
||||
relays := slices.Clone(relayHints)
|
||||
relays = nostr.AppendUnique(relays, pointer.Relays...)
|
||||
if pointer.Author != nostr.ZeroPK {
|
||||
relays = nostr.AppendUnique(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
|
||||
}
|
||||
if len(relays) == 0 {
|
||||
return nil, fmt.Errorf("no relay hints found for episode")
|
||||
}
|
||||
|
||||
res := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}}, nostr.SubscriptionOptions{Label: "nak-podcast"})
|
||||
if res == nil {
|
||||
return nil, fmt.Errorf("podcast episode not found")
|
||||
}
|
||||
if res.Event.Kind != 54 {
|
||||
return nil, fmt.Errorf("event is not podcast episode: expected kind 54, got %d", res.Event.Kind)
|
||||
}
|
||||
|
||||
return &res.Event, nil
|
||||
}
|
||||
|
||||
func fetchLatestEpisodeForPodcastSelection(ctx context.Context, pk nostr.PubKey, relayHints []string) (*nostr.Event, error) {
|
||||
choices, err := discoverPodcasts(ctx, pk, relayHints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chosen := choices[0]
|
||||
if len(choices) > 1 {
|
||||
labels := make([]string, len(choices))
|
||||
for i, item := range choices {
|
||||
labels[i] = podcastLabel(item)
|
||||
}
|
||||
|
||||
var selected string
|
||||
if err := survey.AskOne(&survey.Select{
|
||||
Message: "choose podcast",
|
||||
Options: labels,
|
||||
PageSize: 12,
|
||||
}, &selected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, label := range labels {
|
||||
if label == selected {
|
||||
chosen = choices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
episode, err := fetchLatestPodcastEpisode(ctx, chosen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return episode, nil
|
||||
}
|
||||
|
||||
func discoverPodcasts(ctx context.Context, pk nostr.PubKey, relayHints []string) ([]podcastInfo, error) {
|
||||
choices := make([]podcastInfo, 0, 4)
|
||||
seen := make(map[nostr.PubKey]struct{}, 4)
|
||||
|
||||
if direct, ok := fetchPodcastMetadata(ctx, pk, relayHints); ok {
|
||||
choices = append(choices, direct)
|
||||
seen[pk] = struct{}{}
|
||||
}
|
||||
|
||||
authoredRelays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
|
||||
for _, candidatePubKey := range fetchAuthoredPodcastPubKeys(ctx, pk, authoredRelays) {
|
||||
if _, ok := seen[candidatePubKey]; ok {
|
||||
continue
|
||||
}
|
||||
meta, ok := fetchPodcastMetadata(ctx, candidatePubKey, relayHints)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !podcastClaimsAuthor(meta.Metadata, pk) {
|
||||
continue
|
||||
}
|
||||
choices = append(choices, meta)
|
||||
seen[candidatePubKey] = struct{}{}
|
||||
}
|
||||
|
||||
if len(choices) == 0 {
|
||||
return nil, fmt.Errorf("no podcasts found for %s", nip19.EncodeNpub(pk))
|
||||
}
|
||||
|
||||
return choices, nil
|
||||
}
|
||||
|
||||
func fetchPodcastMetadata(ctx context.Context, pk nostr.PubKey, relayHints []string) (podcastInfo, bool) {
|
||||
relays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
|
||||
if len(relays) == 0 {
|
||||
return podcastInfo{}, false
|
||||
}
|
||||
|
||||
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10154},
|
||||
Authors: []nostr.PubKey{pk},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
|
||||
|
||||
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
|
||||
if !ok {
|
||||
return podcastInfo{}, false
|
||||
}
|
||||
|
||||
return podcastInfo{
|
||||
PubKey: pk,
|
||||
Title: podcastTagValue(evt, "title"),
|
||||
Image: podcastTagValue(evt, "image"),
|
||||
Relays: relays,
|
||||
Metadata: evt,
|
||||
}, true
|
||||
}
|
||||
|
||||
func fetchAuthoredPodcastPubKeys(ctx context.Context, pk nostr.PubKey, relays []string) []nostr.PubKey {
|
||||
if len(relays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
found := make([]nostr.PubKey, 0, 4)
|
||||
seen := make(map[nostr.PubKey]struct{}, 4)
|
||||
for _, kind := range []nostr.Kind{10064, 10164} {
|
||||
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{kind},
|
||||
Authors: []nostr.PubKey{pk},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
|
||||
|
||||
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 || tag[0] != "p" {
|
||||
continue
|
||||
}
|
||||
podcastPK, err := nostr.PubKeyFromHex(tag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[podcastPK]; ok {
|
||||
continue
|
||||
}
|
||||
seen[podcastPK] = struct{}{}
|
||||
found = append(found, podcastPK)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
func podcastClaimsAuthor(metadata nostr.Event, author nostr.PubKey) bool {
|
||||
for _, tag := range metadata.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "p" && tag[1] == author.Hex() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fetchLatestPodcastEpisode(ctx context.Context, podcast podcastInfo) (*nostr.Event, error) {
|
||||
relays := podcast.Relays
|
||||
if len(relays) == 0 {
|
||||
relays = sys.FetchOutboxRelays(ctx, podcast.PubKey, 3)
|
||||
}
|
||||
if len(relays) == 0 {
|
||||
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(podcast.PubKey))
|
||||
}
|
||||
|
||||
var latest *nostr.Event
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{54},
|
||||
Authors: []nostr.PubKey{podcast.PubKey},
|
||||
Limit: 20,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-podcast"}) {
|
||||
if latest == nil || ie.Event.CreatedAt > latest.CreatedAt {
|
||||
event := ie.Event
|
||||
latest = &event
|
||||
}
|
||||
}
|
||||
|
||||
if latest == nil {
|
||||
return nil, fmt.Errorf("no episodes found for %s", podcastLabel(podcast))
|
||||
}
|
||||
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func playPodcastEpisode(player string, episode *nostr.Event) error {
|
||||
audioURL := firstPodcastAudioURL(*episode)
|
||||
if audioURL == "" {
|
||||
return fmt.Errorf("podcast episode has no audio tag")
|
||||
}
|
||||
|
||||
title := podcastTagValue(*episode, "title")
|
||||
if title == "" {
|
||||
title = episode.ID.Hex()
|
||||
}
|
||||
|
||||
stdout(colors.bold("playing:"), color.HiBlueString(title))
|
||||
stdout(colors.bold("audio:"), color.HiCyanString(audioURL))
|
||||
|
||||
cmd, err := buildPodcastPlayerCommand(player, audioURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = color.Output
|
||||
cmd.Stderr = color.Error
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func buildPodcastPlayerCommand(player string, audioURL string) (*exec.Cmd, error) {
|
||||
if strings.TrimSpace(player) != "" {
|
||||
parts := strings.Fields(player)
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("invalid player command %q", player)
|
||||
}
|
||||
return exec.Command(parts[0], append(parts[1:], audioURL)...), nil
|
||||
}
|
||||
|
||||
for _, candidate := range podcastPlayerCandidates() {
|
||||
if _, err := exec.LookPath(candidate.name); err == nil {
|
||||
return exec.Command(candidate.name, append(candidate.args, audioURL)...), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no media player found; install mpv, vlc, ffplay or pass --player")
|
||||
}
|
||||
|
||||
func podcastPlayerCandidates() []struct {
|
||||
name string
|
||||
args []string
|
||||
} {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"mpv", nil},
|
||||
{"vlc", nil},
|
||||
{"ffplay", []string{"-nodisp", "-autoexit"}},
|
||||
{"open", nil},
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"mpv.exe", nil},
|
||||
{"vlc.exe", nil},
|
||||
{"ffplay.exe", []string{"-nodisp", "-autoexit"}},
|
||||
{"cmd", []string{"/c", "start"}},
|
||||
}
|
||||
}
|
||||
|
||||
return []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"mpv", nil},
|
||||
{"vlc", nil},
|
||||
{"ffplay", []string{"-nodisp", "-autoexit"}},
|
||||
{"xdg-open", nil},
|
||||
}
|
||||
}
|
||||
|
||||
func firstPodcastAudioURL(evt nostr.Event) string {
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "audio" {
|
||||
return tag[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func podcastTagValue(evt nostr.Event, name string) string {
|
||||
if tag := evt.Tags.Find(name); len(tag) >= 2 {
|
||||
return tag[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func podcastLabel(info podcastInfo) string {
|
||||
title := strings.TrimSpace(info.Title)
|
||||
if title == "" {
|
||||
title = nip19.EncodeNpub(info.PubKey)
|
||||
} else {
|
||||
title += " (" + nip19.EncodeNpub(info.PubKey) + ")"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func resolvePodcastsForPubkey(ctx context.Context, target string, relayHints []string) ([]podcastInfo, error) {
|
||||
pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allRelays := nostr.AppendUnique(relayHints, extraRelays...)
|
||||
choices, err := discoverPodcasts(ctx, pk, allRelays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(choices) > 1 {
|
||||
labels := make([]string, len(choices))
|
||||
for i, item := range choices {
|
||||
labels[i] = podcastLabel(item)
|
||||
}
|
||||
|
||||
var selected string
|
||||
if err := survey.AskOne(&survey.Select{
|
||||
Message: "choose podcast",
|
||||
Options: labels,
|
||||
PageSize: 12,
|
||||
}, &selected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]podcastInfo, 0, 1)
|
||||
for i, label := range labels {
|
||||
if label == selected {
|
||||
filtered = append(filtered, choices[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
return choices, nil
|
||||
}
|
||||
|
||||
func printPodcastInfo(p podcastInfo) {
|
||||
stdout(colors.bold("title:"), color.HiBlueString(p.Title))
|
||||
stdout(colors.bold("pubkey:"), color.HiCyanString(nip19.EncodeNpub(p.PubKey)))
|
||||
|
||||
if desc := podcastTagValue(p.Metadata, "description"); desc != "" {
|
||||
stdout(colors.bold("description:"), desc)
|
||||
}
|
||||
if image := podcastTagValue(p.Metadata, "image"); image != "" {
|
||||
stdout(colors.bold("image:"), color.HiCyanString(image))
|
||||
}
|
||||
if website := podcastTagValue(p.Metadata, "website"); website != "" {
|
||||
stdout(colors.bold("website:"), color.HiCyanString(website))
|
||||
}
|
||||
|
||||
authors := make([]string, 0, 2)
|
||||
for _, tag := range p.Metadata.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "p" {
|
||||
pk, err := nostr.PubKeyFromHex(tag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
author := nip19.EncodeNpub(pk)
|
||||
if len(tag) >= 3 && tag[2] != "" {
|
||||
author += " " + tag[2]
|
||||
}
|
||||
authors = append(authors, author)
|
||||
}
|
||||
}
|
||||
if len(authors) > 0 {
|
||||
stdout(colors.bold("authors:"), strings.Join(authors, ", "))
|
||||
}
|
||||
|
||||
stdout("")
|
||||
}
|
||||
|
||||
func printPodcastEpisodeList(ctx context.Context, p podcastInfo, limit int) error {
|
||||
episodes, err := fetchPodcastEpisodes(ctx, p, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(episodes) == 0 {
|
||||
stdout(color.YellowString("no episodes found for %s", p.Title))
|
||||
return nil
|
||||
}
|
||||
|
||||
stdout(color.CyanString("episodes for %s:", p.Title))
|
||||
for _, ep := range episodes {
|
||||
title := podcastTagValue(ep, "title")
|
||||
if title == "" {
|
||||
title = ep.ID.Hex()[:16]
|
||||
}
|
||||
date := ep.CreatedAt.Time().Format("2006-01-02")
|
||||
desc := ep.Content
|
||||
if len(desc) > 100 {
|
||||
desc = desc[:100] + "..."
|
||||
}
|
||||
stdout(fmt.Sprintf("%s %s %s", date, title, desc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchPodcastEpisodes(ctx context.Context, p podcastInfo, limit int) ([]nostr.Event, error) {
|
||||
relays := p.Relays
|
||||
if len(relays) == 0 {
|
||||
relays = sys.FetchOutboxRelays(ctx, p.PubKey, 3)
|
||||
}
|
||||
if len(relays) == 0 {
|
||||
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(p.PubKey))
|
||||
}
|
||||
|
||||
episodes := make([]nostr.Event, 0, limit)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{54},
|
||||
Authors: []nostr.PubKey{p.PubKey},
|
||||
Limit: limit,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-podcast-list"}) {
|
||||
episodes = append(episodes, ie.Event)
|
||||
}
|
||||
|
||||
return episodes, nil
|
||||
}
|
||||
140
profile.go
Normal file
140
profile.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
stdjson "encoding/json"
|
||||
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var profile = &cli.Command{
|
||||
Name: "profile",
|
||||
Usage: "displays profile information for a given pubkey",
|
||||
Description: `fetches and displays profile metadata, relays, and contact count for a given pubkey.
|
||||
|
||||
example usage:
|
||||
nak profile npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps
|
||||
nak profile user@example.com`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "name",
|
||||
Usage: "print only the name",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "picture",
|
||||
Usage: "print only the picture URL",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "about",
|
||||
Usage: "print only the about text",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "metadata",
|
||||
Usage: "print the profile metadata as JSON",
|
||||
},
|
||||
},
|
||||
ArgsUsage: "[pubkey]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := parsePubKey(pubkeyInput)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err)
|
||||
continue
|
||||
}
|
||||
|
||||
pm := sys.FetchProfileMetadata(ctx, pk)
|
||||
|
||||
if c.Bool("name") {
|
||||
stdout(pm.Name)
|
||||
continue
|
||||
}
|
||||
if c.Bool("picture") {
|
||||
stdout(pm.Picture)
|
||||
continue
|
||||
}
|
||||
if c.Bool("about") {
|
||||
stdout(pm.About)
|
||||
continue
|
||||
}
|
||||
if c.Bool("metadata") {
|
||||
j, _ := stdjson.Marshal(pm)
|
||||
stdout(string(j))
|
||||
continue
|
||||
}
|
||||
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
stdout(colors.bold("pubkey (hex):"), pk.Hex())
|
||||
stdout(colors.bold("npub:"), color.HiCyanString(npub))
|
||||
|
||||
relayList := sys.FetchRelayList(ctx, pk)
|
||||
writeRelays := make([]string, 0, 3)
|
||||
for _, rl := range relayList.Items {
|
||||
if rl.Outbox {
|
||||
writeRelays = append(writeRelays, rl.URL)
|
||||
if len(writeRelays) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(writeRelays) > 0 {
|
||||
nprofile := nip19.EncodeNprofile(pk, writeRelays)
|
||||
stdout(colors.bold("profile uri:"), color.HiCyanString("nostr:"+nprofile))
|
||||
}
|
||||
|
||||
if pm.Name != "" {
|
||||
stdout(colors.bold("name:"), color.HiBlueString(pm.Name))
|
||||
}
|
||||
if pm.DisplayName != "" {
|
||||
stdout(colors.bold("display_name:"), color.HiBlueString(pm.DisplayName))
|
||||
}
|
||||
if pm.About != "" {
|
||||
stdout(colors.bold("about:"), color.HiBlueString(pm.About))
|
||||
}
|
||||
if pm.Picture != "" {
|
||||
stdout(colors.bold("picture:"), color.HiBlueString(pm.Picture))
|
||||
}
|
||||
if pm.Banner != "" {
|
||||
stdout(colors.bold("banner:"), color.HiBlueString(pm.Banner))
|
||||
}
|
||||
if pm.Website != "" {
|
||||
stdout(colors.bold("website:"), color.HiBlueString(pm.Website))
|
||||
}
|
||||
if pm.NIP05 != "" {
|
||||
isValid := pm.NIP05Valid(ctx)
|
||||
if isValid {
|
||||
stdout(colors.bold("nip05:"), color.HiGreenString(pm.NIP05), color.HiGreenString("(verified)"))
|
||||
} else {
|
||||
stdout(colors.bold("nip05:"), color.HiRedString(pm.NIP05), color.HiRedString("(not verified)"))
|
||||
}
|
||||
}
|
||||
if pm.LUD16 != "" {
|
||||
stdout(colors.bold("lud16:"), color.HiBlueString(pm.LUD16))
|
||||
}
|
||||
|
||||
if len(relayList.Items) > 0 {
|
||||
stdout(colors.bold("relays:"))
|
||||
for _, relay := range relayList.Items {
|
||||
access := ""
|
||||
if relay.Inbox && relay.Outbox {
|
||||
access = "read/write"
|
||||
} else if relay.Inbox {
|
||||
access = "read"
|
||||
} else if relay.Outbox {
|
||||
access = "write"
|
||||
}
|
||||
stdout(" ", color.HiBlueString(relay.URL), color.HiCyanString("(%s)", access))
|
||||
}
|
||||
}
|
||||
|
||||
followList := sys.FetchFollowList(ctx, pk)
|
||||
contactCount := len(followList.Items)
|
||||
stdout(colors.bold("follows:"), color.HiCyanString("%d", contactCount))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
307
publish.go
Normal file
307
publish.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var publish = &cli.Command{
|
||||
Name: "publish",
|
||||
Usage: "publishes a note with content from stdin or argument",
|
||||
Description: `reads content and publishes it as a note, optionally as a reply to another note.
|
||||
Either pipe content or pass it as the first argument.
|
||||
|
||||
example:
|
||||
echo "hello world" | nak publish
|
||||
nak publish "hello world"
|
||||
echo "I agree!" | nak publish --reply nevent1...
|
||||
nak publish -t t=mytag -t e=someeventid "tagged post"`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also publish to these relays",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reply",
|
||||
Usage: "event id, naddr1 or nevent1 code to reply to",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "comment",
|
||||
Usage: "event id, naddr1 or nevent1 code to comment on (NIP-22 kind 1111, takes precedence over --reply)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "created-at",
|
||||
Aliases: []string{"time", "ts"},
|
||||
Usage: "unix timestamp value for the created_at field",
|
||||
DefaultText: "now",
|
||||
Value: nostr.Now(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "nevent",
|
||||
Usage: "print the nevent code (to stderr) after the event is published",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "confirm",
|
||||
Usage: "ask before publishing the event",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
var content []byte
|
||||
if isPiped() {
|
||||
var err error
|
||||
content, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
} else if firstArg := c.Args().First(); firstArg != "" {
|
||||
content = []byte(firstArg)
|
||||
} else {
|
||||
return fmt.Errorf("no content. pipe or pass content as argument.")
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: 1,
|
||||
Content: strings.TrimSpace(string(content)),
|
||||
Tags: make(nostr.Tags, 0, 4),
|
||||
CreatedAt: nostr.Now(),
|
||||
}
|
||||
|
||||
// handle timestamp flag
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
}
|
||||
|
||||
// handle reply/comment flag
|
||||
// --comment takes precedence over --reply
|
||||
target := c.String("comment")
|
||||
isComment := c.IsSet("comment")
|
||||
if target == "" {
|
||||
target = c.String("reply")
|
||||
}
|
||||
var replyRelays []string
|
||||
if target != "" {
|
||||
replyEvent, relayHints, err := sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch reply target event: %w", err)
|
||||
}
|
||||
|
||||
if isComment || replyEvent.Kind != 1 {
|
||||
// NIP-22 comment
|
||||
evt.Kind = 1111
|
||||
buildNIP22Tags(&evt, replyEvent, relayHints)
|
||||
} else {
|
||||
// NIP-10 reply
|
||||
if replyEvent.Kind != 1 {
|
||||
evt.Kind = 1111
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
|
||||
}
|
||||
|
||||
// add reply tags per NIP-10
|
||||
rootID := ""
|
||||
rootRelay := ""
|
||||
for _, tag := range replyEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "e" {
|
||||
if len(tag) >= 4 && tag[3] == "root" {
|
||||
rootID = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootRelay = tag[2]
|
||||
}
|
||||
break
|
||||
}
|
||||
if rootID == "" {
|
||||
rootID = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootRelay = tag[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rootID == "" {
|
||||
// replying to root event
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"e", replyEvent.ID.Hex(), "", "root"},
|
||||
)
|
||||
} else {
|
||||
// replying to a reply
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"e", rootID, rootRelay, "root"},
|
||||
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
|
||||
)
|
||||
}
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"p", replyEvent.PubKey.Hex()},
|
||||
)
|
||||
}
|
||||
|
||||
replyRelays = sys.FetchInboxRelays(ctx, replyEvent.PubKey, 3)
|
||||
}
|
||||
|
||||
// handle other tags -- copied from event.go
|
||||
tagFlags := c.StringSlice("tag")
|
||||
for _, tagFlag := range tagFlags {
|
||||
// tags are in the format key=value
|
||||
tagName, tagValue, found := strings.Cut(tagFlag, "=")
|
||||
tag := []string{tagName}
|
||||
if found {
|
||||
// tags may also contain extra elements separated with a ";"
|
||||
tagValues := strings.Split(tagValue, ";")
|
||||
tag = append(tag, tagValues...)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
|
||||
// process the content
|
||||
targetRelays := sys.PrepareNoteEvent(ctx, &evt)
|
||||
|
||||
// connect to all the relays (like event.go)
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get our public key: %w", err)
|
||||
}
|
||||
|
||||
relayUrls := sys.FetchWriteRelays(ctx, pk)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, c.StringSlice("relay")...)
|
||||
|
||||
relays := connectToAllRelays(ctx, c, relayUrls)
|
||||
|
||||
if len(relays) == 0 {
|
||||
if len(relayUrls) == 0 {
|
||||
return fmt.Errorf("no relays to publish this note to.")
|
||||
} else {
|
||||
return fmt.Errorf("failed to connect to any of [ %v ].", relayUrls)
|
||||
}
|
||||
}
|
||||
|
||||
// sign the event
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("error signing event: %w", err)
|
||||
}
|
||||
|
||||
// print
|
||||
stdout(evt.String())
|
||||
|
||||
// publish (like event.go)
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
},
|
||||
}
|
||||
|
||||
func buildNIP22Tags(evt *nostr.Event, target *nostr.Event, relayHints []string) {
|
||||
var relayHint string
|
||||
if len(relayHints) > 0 {
|
||||
relayHint = relayHints[0]
|
||||
}
|
||||
|
||||
dtag := func() string {
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "d" {
|
||||
return tag[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// find root scope in target's uppercase NIP-22 tags
|
||||
var rootScopeName, rootScopeValue, rootScopeRelay, rootKind, rootPubkey string
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && (tag[0] == "E" || tag[0] == "A" || tag[0] == "I") {
|
||||
rootScopeName = tag[0]
|
||||
rootScopeValue = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootScopeRelay = tag[2]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if rootScopeName != "" {
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "K" {
|
||||
rootKind = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "P" {
|
||||
rootPubkey = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
|
||||
rootScopeName = "A"
|
||||
rootScopeValue = fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
|
||||
} else {
|
||||
rootScopeName = "E"
|
||||
rootScopeValue = target.ID.Hex()
|
||||
}
|
||||
rootScopeRelay = relayHint
|
||||
rootKind = fmt.Sprint(target.Kind)
|
||||
rootPubkey = target.PubKey.Hex()
|
||||
}
|
||||
|
||||
// root scope tag
|
||||
rootScopeTag := nostr.Tag{rootScopeName, rootScopeValue}
|
||||
if rootScopeName == "E" && rootPubkey != "" {
|
||||
// the pubkey goes in the 4th position, so the relay hint must be
|
||||
// present (even if empty) to keep it there
|
||||
rootScopeTag = append(rootScopeTag, rootScopeRelay, rootPubkey)
|
||||
} else if rootScopeRelay != "" {
|
||||
rootScopeTag = append(rootScopeTag, rootScopeRelay)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, rootScopeTag)
|
||||
|
||||
if rootKind != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"K", rootKind})
|
||||
}
|
||||
if rootPubkey != "" && rootScopeName != "I" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"P", rootPubkey})
|
||||
}
|
||||
|
||||
// parent tags (lowercase) - always reference the target event
|
||||
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
|
||||
aValue := fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
|
||||
aTag := nostr.Tag{"a", aValue}
|
||||
if relayHint != "" {
|
||||
aTag = append(aTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, aTag)
|
||||
}
|
||||
eTag := nostr.Tag{"e", target.ID.Hex()}
|
||||
if target.PubKey != (nostr.PubKey{}) {
|
||||
// same here: keep the pubkey in the 4th position
|
||||
eTag = append(eTag, relayHint, target.PubKey.Hex())
|
||||
} else if relayHint != "" {
|
||||
eTag = append(eTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, eTag)
|
||||
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"k", fmt.Sprint(target.Kind)})
|
||||
|
||||
pTag := nostr.Tag{"p", target.PubKey.Hex()}
|
||||
if relayHint != "" {
|
||||
pTag = append(pTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, pTag)
|
||||
}
|
||||
182
relay.go
182
relay.go
@@ -1,30 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip11"
|
||||
"github.com/nbd-wtf/go-nostr/nip86"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var relay = &cli.Command{
|
||||
Name: "relay",
|
||||
Usage: "gets the relay information document for the given relay, as JSON -- or allows usage of the relay management API.",
|
||||
Description: `examples:
|
||||
fetching relay information:
|
||||
Usage: "gets the relay information document for the given relay, as JSON",
|
||||
Description: `
|
||||
nak relay nostr.wine
|
||||
|
||||
managing a relay
|
||||
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 {
|
||||
@@ -35,171 +24,14 @@ var relay = &cli.Command{
|
||||
|
||||
info, err := nip11.Fetch(ctx, url)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
pretty, _ := json.MarshalIndent(info, "", " ")
|
||||
stdout(string(pretty))
|
||||
}
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
Commands: (func() []*cli.Command {
|
||||
commands := make([]*cli.Command, 0, 12)
|
||||
|
||||
for _, def := range []struct {
|
||||
method string
|
||||
args []string
|
||||
}{
|
||||
{"allowpubkey", []string{"pubkey", "reason"}},
|
||||
{"banpubkey", []string{"pubkey", "reason"}},
|
||||
{"listallowedpubkeys", nil},
|
||||
{"allowpubkey", []string{"pubkey", "reason"}},
|
||||
{"listallowedpubkeys", nil},
|
||||
{"listeventsneedingmoderation", nil},
|
||||
{"allowevent", []string{"id", "reason"}},
|
||||
{"banevent", []string{"id", "reason"}},
|
||||
{"listbannedevents", nil},
|
||||
{"changerelayname", []string{"name"}},
|
||||
{"changerelaydescription", []string{"description"}},
|
||||
{"changerelayicon", []string{"icon"}},
|
||||
{"allowkind", []string{"kind"}},
|
||||
{"disallowkind", []string{"kind"}},
|
||||
{"listallowedkinds", nil},
|
||||
{"blockip", []string{"ip", "reason"}},
|
||||
{"unblockip", []string{"ip", "reason"}},
|
||||
{"listblockedips", nil},
|
||||
} {
|
||||
def := def
|
||||
|
||||
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
|
||||
for i, argName := range def.args {
|
||||
flags[i] = declareFlag(argName)
|
||||
}
|
||||
|
||||
flags = append(flags, 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),
|
||||
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
|
||||
},
|
||||
Flags: flags,
|
||||
}
|
||||
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands
|
||||
})(),
|
||||
}
|
||||
|
||||
func declareFlag(argName string) cli.Flag {
|
||||
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
|
||||
switch argName {
|
||||
case "kind":
|
||||
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
|
||||
case "reason":
|
||||
return &cli.StringFlag{Name: argName, Usage: usage}
|
||||
default:
|
||||
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
|
||||
}
|
||||
}
|
||||
|
||||
func getArgument(c *cli.Command, argName string) any {
|
||||
switch argName {
|
||||
case "kind":
|
||||
return c.Int(argName)
|
||||
default:
|
||||
return c.String(argName)
|
||||
}
|
||||
}
|
||||
|
||||
569
req.go
569
req.go
@@ -1,14 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/eventstore"
|
||||
"fiatjaf.com/nostr/eventstore/slicestore"
|
||||
"fiatjaf.com/nostr/eventstore/wrappers"
|
||||
"fiatjaf.com/nostr/nip77"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,7 +30,7 @@ const (
|
||||
var req = &cli.Command{
|
||||
Name: "req",
|
||||
Usage: "generates encoded REQ messages and optionally use them to talk to relays",
|
||||
Description: `outputs a NIP-01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
|
||||
Description: `outputs a nip01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
|
||||
|
||||
example:
|
||||
nak req -k 1 -l 15 wss://nostr.wine wss://nostr-pub.wellorder.net
|
||||
@@ -30,67 +41,109 @@ it can also take a filter from stdin, optionally modify it with flags and send i
|
||||
example:
|
||||
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
append(reqFilterFlags,
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep the subscription open, printing all events as they are returned",
|
||||
DefaultText: "false, will close on EOSE",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "paginate",
|
||||
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
|
||||
DefaultText: "false",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "paginate-interval",
|
||||
Usage: "time between queries when using --paginate",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "paginate-global-limit",
|
||||
Usage: "global limit at which --paginate should stop",
|
||||
DefaultText: "uses the value given by --limit/-l or infinite",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bare",
|
||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force-pre-auth",
|
||||
Aliases: []string{"fpa"},
|
||||
Usage: "after connecting, for a NIP-42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
)...,
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-verify",
|
||||
Usage: "skip event signature verification from relays",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "only-missing",
|
||||
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ids-only",
|
||||
Usage: "use nip77 to fetch just a list of ids",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep the subscription open, printing all events as they are returned",
|
||||
DefaultText: "false, will close on EOSE",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "outbox-relays-per-pubkey",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "number of outbox relays to use for each pubkey",
|
||||
Value: 3,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "paginate",
|
||||
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
|
||||
DefaultText: "false",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "paginate-interval",
|
||||
Usage: "time between queries when using --paginate",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bare",
|
||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "spell",
|
||||
Usage: "output a spell event (kind 777) instead of a filter",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Bool("no-verify") {
|
||||
sys.Pool.RelayOptions.AssumeValid = true
|
||||
}
|
||||
|
||||
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
|
||||
if negentropy {
|
||||
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
|
||||
return fmt.Errorf("negentropy is incompatible with --stream, --outbox or --paginate")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bool("paginate") && c.Bool("stream") {
|
||||
return fmt.Errorf("incompatible flags --paginate and --stream")
|
||||
}
|
||||
|
||||
if c.Bool("paginate") && c.Bool("outbox") {
|
||||
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
||||
}
|
||||
|
||||
if c.Bool("bare") && c.Bool("spell") {
|
||||
return fmt.Errorf("incompatible flags --bare and --spell")
|
||||
}
|
||||
|
||||
relayUrls := c.Args().Slice()
|
||||
if len(relayUrls) > 0 {
|
||||
relays := connectToAllRelays(ctx,
|
||||
|
||||
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
|
||||
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") {
|
||||
return fmt.Errorf("--jq requires relay URLs or --outbox")
|
||||
}
|
||||
|
||||
if len(relayUrls) > 0 && !negentropy {
|
||||
relays := connectToAllRelays(
|
||||
ctx,
|
||||
c,
|
||||
relayUrls,
|
||||
c.Bool("force-pre-auth"),
|
||||
nostr.WithAuthHandler(
|
||||
func(ctx context.Context, authEvent nostr.RelayEvent) error {
|
||||
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
|
||||
return fmt.Errorf("auth not authorized")
|
||||
}
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
log("performing auth as %s... ", pk)
|
||||
|
||||
return kr.SignEvent(ctx, authEvent.Event)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// stop here already if all connections failed
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
@@ -99,15 +152,10 @@ example:
|
||||
for i, relay := range relays {
|
||||
relayUrls[i] = relay.URL
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, relay := range relays {
|
||||
relay.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for stdinFilter := range getStdinLinesOrBlank() {
|
||||
// go line by line from stdin or run once with input from flags
|
||||
for stdinFilter := range getJsonsOrBlank() {
|
||||
filter := nostr.Filter{}
|
||||
if stdinFilter != "" {
|
||||
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
||||
@@ -120,27 +168,93 @@ example:
|
||||
return err
|
||||
}
|
||||
|
||||
if len(relayUrls) > 0 {
|
||||
fn := sys.Pool.SubManyEose
|
||||
if c.Bool("paginate") {
|
||||
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
|
||||
} else if c.Bool("stream") {
|
||||
fn = sys.Pool.SubMany
|
||||
}
|
||||
if len(relayUrls) > 0 || c.Bool("outbox") {
|
||||
if negentropy {
|
||||
store := &slicestore.SliceStore{}
|
||||
store.Init()
|
||||
|
||||
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
|
||||
stdout(ie.Event)
|
||||
if syncFile := c.String("only-missing"); syncFile != "" {
|
||||
file, err := os.Open(syncFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open sync file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
|
||||
for scanner.Scan() {
|
||||
var evt nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := store.SaveEvent(evt); err != nil {
|
||||
if err == eventstore.ErrDupEvent {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to save event from sync file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read sync file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
target := PrintingQuerierPublisher{
|
||||
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
|
||||
jq: jq,
|
||||
}
|
||||
|
||||
var source nostr.Querier = nil
|
||||
if c.IsSet("only-missing") {
|
||||
source = target
|
||||
}
|
||||
|
||||
handle := nip77.SyncEventsFromIDs
|
||||
|
||||
if c.Bool("ids-only") {
|
||||
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
|
||||
handle = func(ctx context.Context, dir nip77.Direction) {
|
||||
for id := range dir.Items {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
stdout(id.Hex())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, url := range relayUrls {
|
||||
err := nip77.NegentropySync(ctx, url, filter, source, target, handle)
|
||||
if err != nil {
|
||||
log("negentropy sync from %s failed: %s", url, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
// no relays given, will just print the filter or spell
|
||||
var result string
|
||||
if c.Bool("bare") {
|
||||
if c.Bool("spell") {
|
||||
// output a spell event instead of a filter
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spellEvent := createSpellEvent(ctx, filter, kr)
|
||||
j, _ := json.Marshal(spellEvent)
|
||||
result = string(j)
|
||||
} else if c.Bool("bare") {
|
||||
// bare filter output
|
||||
result = filter.String()
|
||||
} else {
|
||||
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: nostr.Filters{filter}})
|
||||
// normal filter
|
||||
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
|
||||
result = string(j)
|
||||
}
|
||||
|
||||
}
|
||||
stdout(result)
|
||||
}
|
||||
}
|
||||
@@ -150,23 +264,204 @@ example:
|
||||
},
|
||||
}
|
||||
|
||||
func performReq(
|
||||
ctx context.Context,
|
||||
filter nostr.Filter,
|
||||
relayUrls []string,
|
||||
stream bool,
|
||||
outbox bool,
|
||||
outboxRelaysPerPubKey uint64,
|
||||
paginate bool,
|
||||
paginateInterval time.Duration,
|
||||
label string,
|
||||
jq jqProcessor,
|
||||
) error {
|
||||
var results chan nostr.RelayEvent
|
||||
var closeds chan nostr.RelayClosed
|
||||
|
||||
opts := nostr.SubscriptionOptions{
|
||||
Label: label,
|
||||
}
|
||||
|
||||
if paginate {
|
||||
paginator := sys.Pool.PaginatorWithInterval(paginateInterval)
|
||||
results = paginator(ctx, relayUrls, filter, opts)
|
||||
} else if outbox {
|
||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||
|
||||
pTagPubkeys := make([]nostr.PubKey, 0, 4)
|
||||
if len(filter.Authors) == 0 && filter.Tags != nil {
|
||||
seen := make(map[nostr.PubKey]struct{})
|
||||
pTags := append([]string{}, filter.Tags["p"]...)
|
||||
pTags = append(pTags, filter.Tags["P"]...)
|
||||
for _, value := range pTags {
|
||||
pubkey, err := parsePubKey(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[pubkey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pubkey] = struct{}{}
|
||||
pTagPubkeys = append(pTagPubkeys, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, relayUrl := range relayUrls {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: relayUrl,
|
||||
})
|
||||
}
|
||||
|
||||
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
|
||||
// relays for p tags when no authors are given
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
|
||||
for _, pubkey := range pTagPubkeys {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
mu.Lock()
|
||||
if slices.IndexFunc(defs, matchUrl) == -1 {
|
||||
// new relay, add it
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: url,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
} else {
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
mu.Lock()
|
||||
if idx := slices.IndexFunc(defs, matchUrl); idx == -1 {
|
||||
// new relay, add it
|
||||
filter := filter.Clone()
|
||||
filter.Authors = []nostr.PubKey{pubkey}
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: url,
|
||||
})
|
||||
} else {
|
||||
// existing relay, add this pubkey
|
||||
defs[idx].Authors = append(defs[idx].Authors, pubkey)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
}
|
||||
|
||||
if stream {
|
||||
logverbose("running subscription with %d directed filters...\n", len(defs))
|
||||
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
|
||||
} else {
|
||||
logverbose("running query with %d directed filters...\n", len(defs))
|
||||
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
|
||||
}
|
||||
} else {
|
||||
if stream {
|
||||
logverbose("running subscription to %d relays...\n", len(relayUrls))
|
||||
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||
} else {
|
||||
logverbose("running query to %d relays...\n", len(relayUrls))
|
||||
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||
}
|
||||
}
|
||||
|
||||
readevents:
|
||||
for {
|
||||
select {
|
||||
case ie, stillOpen := <-results:
|
||||
if !stillOpen {
|
||||
break readevents
|
||||
}
|
||||
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
v, matches, err := jq(ie.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
|
||||
case closed, stillOpen := <-closeds:
|
||||
if stillOpen {
|
||||
if closed.HandledAuth {
|
||||
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
} else {
|
||||
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break readevents
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var reqFilterFlags = []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors (pubkey as hex)",
|
||||
Usage: "only accept events from these authors",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
&IDSliceFlag{
|
||||
Name: "id",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "only accept events with these ids (hex)",
|
||||
Usage: "only accept events with these ids",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
&KindSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "only accept events with these kind numbers",
|
||||
Usage: "only accept events with these kind numbers or kind names",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
@@ -190,6 +485,11 @@ var reqFilterFlags = []cli.Flag{
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
@@ -210,41 +510,78 @@ var reqFilterFlags = []cli.Flag{
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "search",
|
||||
Usage: "a NIP-50 search query, use it only with relays that explicitly support it",
|
||||
Usage: "a nip50 search query, use it only with relays that explicitly support it",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
type flagTag struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
if authors := c.StringSlice("author"); len(authors) > 0 {
|
||||
filter.Authors = append(filter.Authors, authors...)
|
||||
tags := make([]flagTag, 0, 5)
|
||||
|
||||
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
|
||||
for _, author := range as {
|
||||
|
||||
// is it an address?
|
||||
if author.Addr != nil {
|
||||
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if author.PubKey != nostr.ZeroPK {
|
||||
filter.Authors = append(filter.Authors, author.PubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids := c.StringSlice("id"); len(ids) > 0 {
|
||||
|
||||
if ids := getIDSlice(c, "id"); len(ids) > 0 {
|
||||
filter.IDs = append(filter.IDs, ids...)
|
||||
}
|
||||
for _, kind64 := range c.IntSlice("kind") {
|
||||
filter.Kinds = append(filter.Kinds, int(kind64))
|
||||
if kinds := getKindSlice(c, "kind"); len(kinds) > 0 {
|
||||
filter.Kinds = append(filter.Kinds, kinds...)
|
||||
}
|
||||
if search := c.String("search"); search != "" {
|
||||
filter.Search = search
|
||||
}
|
||||
tags := make([][]string, 0, 5)
|
||||
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
tags = append(tags, spl)
|
||||
val := spl[1]
|
||||
if len(spl[0]) == 1 {
|
||||
val = decodeTagValue(val, rune(spl[0][0]))
|
||||
}
|
||||
tags = append(tags, flagTag{spl[0], val})
|
||||
} else {
|
||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||
}
|
||||
}
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = append(tags, []string{"e", etag})
|
||||
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", ptag})
|
||||
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
tags = append(tags, []string{"d", dtag})
|
||||
tags = append(tags, flagTag{"d", dtag})
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
tags = append(tags, flagTag{"h", htag})
|
||||
}
|
||||
|
||||
if len(tags) > 0 && filter.Tags == nil {
|
||||
@@ -252,20 +589,17 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
if _, ok := filter.Tags[tag.key]; !ok {
|
||||
filter.Tags[tag.key] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
|
||||
}
|
||||
|
||||
if c.IsSet("since") {
|
||||
nts := getNaturalDate(c, "since")
|
||||
filter.Since = &nts
|
||||
filter.Since = getNaturalDate(c, "since")
|
||||
}
|
||||
|
||||
if c.IsSet("until") {
|
||||
nts := getNaturalDate(c, "until")
|
||||
filter.Until = &nts
|
||||
filter.Until = getNaturalDate(c, "until")
|
||||
}
|
||||
|
||||
if limit := c.Uint("limit"); limit != 0 {
|
||||
@@ -276,3 +610,32 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type PrintingQuerierPublisher struct {
|
||||
nostr.QuerierPublisher
|
||||
jq jqProcessor
|
||||
}
|
||||
|
||||
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
|
||||
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
|
||||
var out string
|
||||
if p.jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := p.jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
return nil
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
return nil
|
||||
} else if err == eventstore.ErrDupEvent {
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
263
serve.go
263
serve.go
@@ -2,18 +2,28 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/eventstore/slicestore"
|
||||
"fiatjaf.com/nostr/khatru"
|
||||
"fiatjaf.com/nostr/khatru/blossom"
|
||||
"fiatjaf.com/nostr/khatru/grasp"
|
||||
"github.com/bep/debounce"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/fiatjaf/eventstore/slicestore"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var serve = &cli.Command{
|
||||
@@ -36,9 +46,38 @@ var serve = &cli.Command{
|
||||
Usage: "file containing the initial batch of events that will be served by the relay as newline-separated JSON (jsonl)",
|
||||
DefaultText: "the relay will start empty",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "negentropy",
|
||||
Usage: "enable negentropy syncing",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "grasp",
|
||||
Usage: "enable grasp server",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "grasp-path",
|
||||
Usage: "where to store the repositories",
|
||||
TakesFile: true,
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "blossom",
|
||||
Usage: "enable blossom server",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "require AUTH for all operations",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "eager-auth",
|
||||
Usage: "send AUTH challenge immediately on connect",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
db := slicestore.SliceStore{MaxLimit: math.MaxInt}
|
||||
db := &slicestore.SliceStore{}
|
||||
|
||||
var blobStore *xsync.MapOf[string, []byte]
|
||||
var repoDir string
|
||||
|
||||
var scanner *bufio.Scanner
|
||||
if path := c.String("events"); path != "" {
|
||||
@@ -59,66 +98,202 @@ var serve = &cli.Command{
|
||||
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
|
||||
return fmt.Errorf("invalid event received at line %d: %s (`%s`)", i, err, scanner.Text())
|
||||
}
|
||||
db.SaveEvent(ctx, &evt)
|
||||
db.SaveEvent(evt)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
rl := khatru.NewRelay()
|
||||
rl.QueryEvents = append(rl.QueryEvents, db.QueryEvents)
|
||||
rl.CountEvents = append(rl.CountEvents, db.CountEvents)
|
||||
rl.DeleteEvent = append(rl.DeleteEvent, db.DeleteEvent)
|
||||
rl.StoreEvent = append(rl.StoreEvent, db.SaveEvent)
|
||||
|
||||
started := make(chan bool)
|
||||
rl.Info.Name = "nak serve"
|
||||
rl.Info.Description = "a local relay for testing, debugging and development."
|
||||
rl.Info.Software = "https://github.com/fiatjaf/nak"
|
||||
rl.Info.Version = version
|
||||
|
||||
rl.UseEventstore(db, 500)
|
||||
|
||||
if c.Bool("negentropy") {
|
||||
rl.Negentropy = true
|
||||
}
|
||||
|
||||
exited := make(chan error)
|
||||
|
||||
hostname := c.String("hostname")
|
||||
port := int(c.Uint("port"))
|
||||
|
||||
go func() {
|
||||
err := rl.Start(hostname, port, started)
|
||||
exited <- err
|
||||
}()
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
if c.Bool("eager-auth") {
|
||||
khatru.RequestAuth(ctx)
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
totalConnections.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Sprintf
|
||||
italic := color.New(color.Italic).Sprint
|
||||
|
||||
var printStatus func()
|
||||
|
||||
// relay logging
|
||||
rl.RejectFilter = append(rl.RejectFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.HiYellowString("request"), italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
})
|
||||
rl.RejectCountFilter = append(rl.RejectCountFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.HiCyanString("count request"), italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
})
|
||||
rl.RejectEvent = append(rl.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.BlueString("event"), italic(event))
|
||||
printStatus()
|
||||
return false, ""
|
||||
})
|
||||
rl.OnAuth = func(ctx context.Context, pubkey nostr.PubKey) {
|
||||
log(" got %s %s\n", color.GreenString("authenticated"), pubkey.Hex())
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
var printStatus func()
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents := 0
|
||||
ch, _ := db.QueryEvents(ctx, nostr.Filter{})
|
||||
for range ch {
|
||||
totalEvents++
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
if err != nil {
|
||||
log("failed to count: %s\n", err)
|
||||
}
|
||||
subs := rl.GetListeningFilters()
|
||||
|
||||
log(" %s events stored: %s, subscriptions opened: %s\n", color.HiMagentaString("•"), color.HiMagentaString("%d", totalEvents), color.HiMagentaString("%d", len(subs)))
|
||||
blossomMsg := ""
|
||||
if c.Bool("blossom") {
|
||||
blobsStored := blobStore.Size()
|
||||
blossomMsg = fmt.Sprintf("blobs: %s, ",
|
||||
color.HiMagentaString("%d", blobsStored),
|
||||
)
|
||||
}
|
||||
|
||||
graspMsg := ""
|
||||
if c.Bool("grasp") {
|
||||
gitAnnounced := 0
|
||||
gitStored := 0
|
||||
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
|
||||
gitAnnounced++
|
||||
identifier := evt.Tags.GetD()
|
||||
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
|
||||
gitStored++
|
||||
}
|
||||
}
|
||||
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
|
||||
color.HiMagentaString("%d", gitAnnounced),
|
||||
color.HiMagentaString("%d", gitStored),
|
||||
)
|
||||
}
|
||||
|
||||
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
|
||||
color.HiMagentaString("•"),
|
||||
color.HiMagentaString("%d", totalEvents),
|
||||
blossomMsg,
|
||||
graspMsg,
|
||||
color.HiMagentaString("%d", totalConnections.Load()),
|
||||
color.HiMagentaString("%d", len(subs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
<-started
|
||||
log("%s relay running at %s\n", color.HiRedString(">"), bold("ws://%s:%d", hostname, port))
|
||||
if c.Bool("blossom") {
|
||||
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
|
||||
bs.Store = blossom.NewMemoryBlobIndex()
|
||||
|
||||
blobStore = xsync.NewMapOf[string, []byte]()
|
||||
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
|
||||
blobStore.Store(sha256+ext, body)
|
||||
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
|
||||
if body, ok := blobStore.Load(sha256 + ext); ok {
|
||||
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
|
||||
printStatus()
|
||||
return bytes.NewReader(body), nil, nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
|
||||
blobStore.Delete(sha256 + ext)
|
||||
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bool("grasp") {
|
||||
repoDir = c.String("grasp-path")
|
||||
if repoDir == "" {
|
||||
var err error
|
||||
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create grasp repos directory: %w", err)
|
||||
}
|
||||
}
|
||||
g := grasp.New(rl, repoDir)
|
||||
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.YellowString("git write"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
exited <- http.Serve(ln, rl)
|
||||
}()
|
||||
|
||||
// relay logging
|
||||
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: subscribe"
|
||||
}
|
||||
}
|
||||
|
||||
negentropy := ""
|
||||
if khatru.IsNegentropySession(ctx) {
|
||||
negentropy = color.HiBlueString("negentropy ")
|
||||
}
|
||||
|
||||
authedString := ""
|
||||
if pubkey, ok := khatru.GetAuthed(ctx); ok {
|
||||
authedString = fmt.Sprintf(" from %s", color.GreenString(pubkey.Hex()))
|
||||
}
|
||||
|
||||
log(" got %s%s %v%s\n",
|
||||
negentropy, color.HiYellowString("request"), colors.italic(filter), authedString)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: count"
|
||||
}
|
||||
}
|
||||
|
||||
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: event"
|
||||
}
|
||||
}
|
||||
|
||||
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
|
||||
if c.Bool("grasp") {
|
||||
log(" (grasp repos at %s)", repoDir)
|
||||
}
|
||||
log("\n")
|
||||
|
||||
return <-exited
|
||||
},
|
||||
|
||||
536
spell.go
Normal file
536
spell.go
Normal file
@@ -0,0 +1,536 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk/hints"
|
||||
"github.com/fatih/color"
|
||||
"github.com/markusmobius/go-dateparser"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var spell = &cli.Command{
|
||||
Name: "spell",
|
||||
Usage: "downloads a spell event and executes its REQ request",
|
||||
ArgsUsage: "[nevent_code]",
|
||||
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringFlag{
|
||||
Name: "pub",
|
||||
Usage: "public key to run spells in the context of (if you don't want to pass a --sec)",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "outbox-relays-per-pubkey",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "number of outbox relays to use for each pubkey",
|
||||
Value: 3,
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
configPath := c.String("config-path")
|
||||
os.MkdirAll(filepath.Join(configPath, "spells"), 0755)
|
||||
|
||||
// load history from file
|
||||
var history []SpellHistoryEntry
|
||||
historyPath := filepath.Join(configPath, "spells/history")
|
||||
file, err := os.Open(historyPath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
var entry SpellHistoryEntry
|
||||
if err := json.Unmarshal([]byte(scanner.Text()), &entry); err != nil {
|
||||
continue // skip invalid entries
|
||||
}
|
||||
history = append(history, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Args().Len() == 0 {
|
||||
// check if we have input from stdin
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
if stdinEvent == "{}" {
|
||||
break
|
||||
}
|
||||
|
||||
var spell nostr.Event
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &spell); err != nil {
|
||||
return fmt.Errorf("failed to parse spell event from stdin: %w", err)
|
||||
}
|
||||
if spell.Kind != 777 {
|
||||
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
|
||||
}
|
||||
|
||||
return runSpell(ctx, c, historyPath, history, nostr.EventPointer{ID: spell.ID}, spell)
|
||||
}
|
||||
|
||||
// no stdin input, show recent spells
|
||||
log("recent spells:\n")
|
||||
for i, entry := range history {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
|
||||
displayName := entry.Name
|
||||
if displayName == "" {
|
||||
displayName = clampWithEllipsis(entry.Content, 28)
|
||||
}
|
||||
if displayName != "" {
|
||||
displayName = color.HiMagentaString(displayName) + ": "
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(entry.Content, 50)
|
||||
|
||||
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
|
||||
stdout(fmt.Sprintf(" %s %s%s - %s",
|
||||
color.BlueString(entry.Identifier),
|
||||
displayName,
|
||||
color.YellowString(lastUsed),
|
||||
desc,
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decode nevent to get the spell event
|
||||
var pointer nostr.EventPointer
|
||||
identifier := c.Args().First()
|
||||
prefix, value, err := nip19.Decode(identifier)
|
||||
if err == nil {
|
||||
if prefix != "nevent" {
|
||||
return fmt.Errorf("expected nevent code, got %s", prefix)
|
||||
}
|
||||
pointer = value.(nostr.EventPointer)
|
||||
} else {
|
||||
// search our history
|
||||
for _, entry := range history {
|
||||
if entry.Identifier == identifier || entry.Name == identifier {
|
||||
pointer = entry.Pointer
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pointer.ID == nostr.ZeroID {
|
||||
return fmt.Errorf("invalid spell reference")
|
||||
}
|
||||
|
||||
// first try to fetch spell from sys.Store
|
||||
var spell nostr.Event
|
||||
found := false
|
||||
for evt := range sys.Store.QueryEvents(nostr.Filter{IDs: []nostr.ID{pointer.ID}}, 1) {
|
||||
spell = evt
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
var relays []string
|
||||
if !found {
|
||||
// if not found in store, fetch from external relays
|
||||
relays = pointer.Relays
|
||||
if pointer.Author != nostr.ZeroPK {
|
||||
for _, url := range relays {
|
||||
sys.Hints.Save(pointer.Author, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
|
||||
}
|
||||
relays = append(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
|
||||
}
|
||||
result := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}},
|
||||
nostr.SubscriptionOptions{Label: "nak-spell-f"})
|
||||
if result == nil {
|
||||
return fmt.Errorf("spell event not found")
|
||||
}
|
||||
spell = result.Event
|
||||
}
|
||||
if spell.Kind != 777 {
|
||||
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
|
||||
}
|
||||
|
||||
return runSpell(ctx, c, historyPath, history, pointer, spell)
|
||||
},
|
||||
}
|
||||
|
||||
func runSpell(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
historyPath string,
|
||||
history []SpellHistoryEntry,
|
||||
pointer nostr.EventPointer,
|
||||
spell nostr.Event,
|
||||
) error {
|
||||
// parse spell tags to build REQ filter
|
||||
spellFilter, err := buildSpellReq(ctx, c, spell.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse spell tags: %w", err)
|
||||
}
|
||||
|
||||
// determine relays to query
|
||||
var spellRelays []string
|
||||
var outbox bool
|
||||
relaysTag := spell.Tags.Find("relays")
|
||||
if relaysTag == nil {
|
||||
// if this tag doesn't exist assume $outbox
|
||||
relaysTag = nostr.Tag{"relays", "$outbox"}
|
||||
}
|
||||
for i := 1; i < len(relaysTag); i++ {
|
||||
switch relaysTag[i] {
|
||||
case "$outbox":
|
||||
outbox = true
|
||||
default:
|
||||
spellRelays = append(spellRelays, relaysTag[i])
|
||||
}
|
||||
}
|
||||
|
||||
stream := !spell.Tags.Has("close-on-eose")
|
||||
|
||||
// fill in the author if we didn't have it
|
||||
pointer.Author = spell.PubKey
|
||||
|
||||
// save spell to sys.Store
|
||||
if err := sys.Store.SaveEvent(spell); err != nil {
|
||||
logverbose("failed to save spell to store: %v\n", err)
|
||||
}
|
||||
|
||||
// add to history before execution
|
||||
{
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
nameTag := spell.Tags.Find("name")
|
||||
var name string
|
||||
if nameTag != nil {
|
||||
name = nameTag[1]
|
||||
}
|
||||
if len(history) > 100 {
|
||||
history = history[:100]
|
||||
}
|
||||
// write back to file
|
||||
file, err := os.Create(historyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(SpellHistoryEntry{
|
||||
Identifier: identifier,
|
||||
Name: name,
|
||||
Content: spell.Content,
|
||||
LastUsed: time.Now(),
|
||||
Pointer: pointer,
|
||||
})
|
||||
file.Write(data)
|
||||
file.Write([]byte{'\n'})
|
||||
for i, entry := range history {
|
||||
if entry.Identifier == identifier {
|
||||
continue
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(entry)
|
||||
file.Write(data)
|
||||
file.Write([]byte{'\n'})
|
||||
|
||||
// limit history size (keep last 100)
|
||||
if i == 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
file.Close()
|
||||
|
||||
logverbose("executing %s: %s relays=%v outbox=%v stream=%v\n",
|
||||
identifier, spellFilter, spellRelays, outbox, stream)
|
||||
}
|
||||
|
||||
// execute
|
||||
logSpellDetails(spell)
|
||||
if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSpellReq(ctx context.Context, c *cli.Command, tags nostr.Tags) (nostr.Filter, error) {
|
||||
filter := nostr.Filter{}
|
||||
|
||||
getMe := func() (nostr.PubKey, error) {
|
||||
if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") {
|
||||
return parsePubKey(c.String("pub"))
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, fmt.Errorf("failed to get keyer: %w", err)
|
||||
}
|
||||
|
||||
pubkey, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, fmt.Errorf("failed to get public key from keyer: %w", err)
|
||||
}
|
||||
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if len(tag) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch tag[0] {
|
||||
case "cmd":
|
||||
if len(tag) < 2 || tag[1] != "REQ" {
|
||||
return nostr.Filter{}, fmt.Errorf("only REQ commands are supported")
|
||||
}
|
||||
|
||||
case "k":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
if kind, err := strconv.Atoi(tag[i]); err == nil {
|
||||
filter.Kinds = append(filter.Kinds, nostr.Kind(kind))
|
||||
}
|
||||
}
|
||||
|
||||
case "authors":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
switch tag[i] {
|
||||
case "$me":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
filter.Authors = append(filter.Authors, me)
|
||||
case "$contacts":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
for _, f := range sys.FetchFollowList(ctx, me).Items {
|
||||
filter.Authors = append(filter.Authors, f.Pubkey)
|
||||
}
|
||||
default:
|
||||
pubkey, err := nostr.PubKeyFromHex(tag[i])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid pubkey '%s' in 'authors': %w", tag[i], err)
|
||||
}
|
||||
filter.Authors = append(filter.Authors, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
case "ids":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
id, err := nostr.IDFromHex(tag[i])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid id '%s' in 'authors': %w", tag[i], err)
|
||||
}
|
||||
filter.IDs = append(filter.IDs, id)
|
||||
}
|
||||
|
||||
case "tag":
|
||||
if len(tag) < 3 {
|
||||
continue
|
||||
}
|
||||
tagName := tag[1]
|
||||
if filter.Tags == nil {
|
||||
filter.Tags = make(nostr.TagMap)
|
||||
}
|
||||
for i := 2; i < len(tag); i++ {
|
||||
switch tag[i] {
|
||||
case "$me":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], me.Hex())
|
||||
case "$contacts":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
for _, f := range sys.FetchFollowList(ctx, me).Items {
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], f.Pubkey.Hex())
|
||||
}
|
||||
default:
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], tag[i])
|
||||
}
|
||||
}
|
||||
|
||||
case "limit":
|
||||
if len(tag) >= 2 {
|
||||
if limit, err := strconv.Atoi(tag[1]); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
case "since":
|
||||
if len(tag) >= 2 {
|
||||
date, err := dateparser.Parse(&dateparser.Configuration{
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, tag[1])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
|
||||
}
|
||||
filter.Since = nostr.Timestamp(date.Time.Unix())
|
||||
}
|
||||
|
||||
case "until":
|
||||
if len(tag) >= 2 {
|
||||
date, err := dateparser.Parse(&dateparser.Configuration{
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, tag[1])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
|
||||
}
|
||||
filter.Until = nostr.Timestamp(date.Time.Unix())
|
||||
}
|
||||
|
||||
case "search":
|
||||
if len(tag) >= 2 {
|
||||
filter.Search = tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func parseRelativeTime(timeStr string) (nostr.Timestamp, error) {
|
||||
// Handle special cases
|
||||
switch timeStr {
|
||||
case "now":
|
||||
return nostr.Now(), nil
|
||||
}
|
||||
|
||||
// Try to parse as relative time (e.g., "7d", "1h", "30m")
|
||||
if strings.HasSuffix(timeStr, "d") {
|
||||
days := strings.TrimSuffix(timeStr, "d")
|
||||
if daysInt, err := strconv.Atoi(days); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(daysInt*24*60*60), nil
|
||||
}
|
||||
} else if strings.HasSuffix(timeStr, "h") {
|
||||
hours := strings.TrimSuffix(timeStr, "h")
|
||||
if hoursInt, err := strconv.Atoi(hours); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(hoursInt*60*60), nil
|
||||
}
|
||||
} else if strings.HasSuffix(timeStr, "m") {
|
||||
minutes := strings.TrimSuffix(timeStr, "m")
|
||||
if minutesInt, err := strconv.Atoi(minutes); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(minutesInt*60), nil
|
||||
}
|
||||
}
|
||||
|
||||
// try to parse as direct timestamp
|
||||
if ts, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
|
||||
return nostr.Timestamp(ts), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("invalid time format: %s", timeStr)
|
||||
}
|
||||
|
||||
type SpellHistoryEntry struct {
|
||||
Identifier string `json:"_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
Pointer nostr.EventPointer `json:"pointer"`
|
||||
}
|
||||
|
||||
func logSpellDetails(spell nostr.Event) {
|
||||
nameTag := spell.Tags.Find("name")
|
||||
name := ""
|
||||
if nameTag != nil {
|
||||
name = clampWithEllipsis(nameTag[1], 28)
|
||||
}
|
||||
if name != "" {
|
||||
name = ": " + color.HiMagentaString(name)
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(spell.Content, 50)
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
|
||||
log("running %s%s - %s\n",
|
||||
color.BlueString(identifier),
|
||||
name,
|
||||
desc,
|
||||
)
|
||||
}
|
||||
|
||||
func createSpellEvent(ctx context.Context, filter nostr.Filter, kr nostr.Keyer) nostr.Event {
|
||||
spell := nostr.Event{
|
||||
Kind: 777,
|
||||
Tags: make(nostr.Tags, 0),
|
||||
}
|
||||
|
||||
// add cmd tag
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"cmd", "REQ"})
|
||||
|
||||
// add kinds
|
||||
if len(filter.Kinds) > 0 {
|
||||
kindTag := nostr.Tag{"k"}
|
||||
for _, kind := range filter.Kinds {
|
||||
kindTag = append(kindTag, strconv.Itoa(int(kind)))
|
||||
}
|
||||
spell.Tags = append(spell.Tags, kindTag)
|
||||
}
|
||||
|
||||
// add authors
|
||||
if len(filter.Authors) > 0 {
|
||||
authorsTag := nostr.Tag{"authors"}
|
||||
for _, author := range filter.Authors {
|
||||
authorsTag = append(authorsTag, author.Hex())
|
||||
}
|
||||
spell.Tags = append(spell.Tags, authorsTag)
|
||||
}
|
||||
|
||||
// add ids
|
||||
if len(filter.IDs) > 0 {
|
||||
idsTag := nostr.Tag{"ids"}
|
||||
for _, id := range filter.IDs {
|
||||
idsTag = append(idsTag, id.Hex())
|
||||
}
|
||||
spell.Tags = append(spell.Tags, idsTag)
|
||||
}
|
||||
|
||||
// add tags
|
||||
for tagName, values := range filter.Tags {
|
||||
if len(values) > 0 {
|
||||
tag := nostr.Tag{"tag", tagName}
|
||||
for _, value := range values {
|
||||
tag = append(tag, value)
|
||||
}
|
||||
spell.Tags = append(spell.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// add limit
|
||||
if filter.Limit > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"limit", strconv.Itoa(filter.Limit)})
|
||||
}
|
||||
|
||||
// add since
|
||||
if filter.Since > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"since", strconv.FormatInt(int64(filter.Since), 10)})
|
||||
}
|
||||
|
||||
// add until
|
||||
if filter.Until > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"until", strconv.FormatInt(int64(filter.Until), 10)})
|
||||
}
|
||||
|
||||
// add search
|
||||
if filter.Search != "" {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"search", filter.Search})
|
||||
}
|
||||
|
||||
if err := kr.SignEvent(ctx, &spell); err != nil {
|
||||
log("failed to sign spell: %s\n", err)
|
||||
}
|
||||
|
||||
return spell
|
||||
}
|
||||
490
sync.go
Normal file
490
sync.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip77"
|
||||
"fiatjaf.com/nostr/nip77/negentropy"
|
||||
"fiatjaf.com/nostr/nip77/negentropy/storage"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var syncCmd = &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "sync events between two relays using negentropy",
|
||||
Description: `uses nip77 negentropy to sync events between two relays`,
|
||||
ArgsUsage: "<relay1> <relay2>",
|
||||
Flags: reqFilterFlags,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("need exactly two relay URLs: source and target")
|
||||
}
|
||||
|
||||
filter := nostr.Filter{}
|
||||
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
peerA, err := NewRelayThirdPartyRemote(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting up %s: %w", args[0], err)
|
||||
}
|
||||
|
||||
peerB, err := NewRelayThirdPartyRemote(ctx, args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting up %s: %w", args[1], err)
|
||||
}
|
||||
|
||||
tpn := NewThirdPartyNegentropy(
|
||||
peerA,
|
||||
peerB,
|
||||
filter,
|
||||
)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Go(func() {
|
||||
err = tpn.Run(ctx)
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
type op struct {
|
||||
src *nostr.Relay
|
||||
dst *nostr.Relay
|
||||
ids []nostr.ID
|
||||
}
|
||||
|
||||
pending := []op{
|
||||
{peerA.relay, peerB.relay, make([]nostr.ID, 0, 30)},
|
||||
{peerB.relay, peerA.relay, make([]nostr.ID, 0, 30)},
|
||||
}
|
||||
|
||||
for delta := range tpn.Deltas {
|
||||
have := delta.Have.relay
|
||||
havenot := delta.HaveNot.relay
|
||||
logverbose("%s has %s, %s doesn't.\n", have.URL, delta.ID.Hex(), havenot.URL)
|
||||
|
||||
idx := 0 // peerA
|
||||
if have == peerB.relay {
|
||||
idx = 1 // peerB
|
||||
}
|
||||
pending[idx].ids = append(pending[idx].ids, delta.ID)
|
||||
|
||||
// every 30 ids do a fetch-and-publish
|
||||
if len(pending[idx].ids) == 30 {
|
||||
for evt := range pending[idx].src.QueryEvents(nostr.Filter{IDs: pending[idx].ids}) {
|
||||
pending[idx].dst.Publish(ctx, evt)
|
||||
}
|
||||
pending[idx].ids = pending[idx].ids[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// do it for the remaining ids
|
||||
for _, op := range pending {
|
||||
if len(op.ids) > 0 {
|
||||
for evt := range op.src.QueryEvents(nostr.Filter{IDs: op.ids}) {
|
||||
op.dst.Publish(ctx, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
type ThirdPartyNegentropy struct {
|
||||
PeerA *RelayThirdPartyRemote
|
||||
PeerB *RelayThirdPartyRemote
|
||||
Filter nostr.Filter
|
||||
|
||||
Deltas chan Delta
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
ID nostr.ID
|
||||
Have *RelayThirdPartyRemote
|
||||
HaveNot *RelayThirdPartyRemote
|
||||
}
|
||||
|
||||
type boundKey string
|
||||
|
||||
func getBoundKey(b negentropy.Bound) boundKey {
|
||||
return boundKey(fmt.Sprintf("%d:%x", b.Timestamp, b.IDPrefix))
|
||||
}
|
||||
|
||||
type RelayThirdPartyRemote struct {
|
||||
relay *nostr.Relay
|
||||
messages chan string
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
|
||||
// fail records the error and closes the messages channel so a Receive()
|
||||
// that is already blocked on it wakes up instead of hanging forever.
|
||||
func (rtpr *RelayThirdPartyRemote) fail(err error) {
|
||||
rtpr.mu.Lock()
|
||||
if rtpr.err == nil {
|
||||
rtpr.err = err
|
||||
close(rtpr.messages)
|
||||
}
|
||||
rtpr.mu.Unlock()
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) getErr() error {
|
||||
rtpr.mu.Lock()
|
||||
defer rtpr.mu.Unlock()
|
||||
return rtpr.err
|
||||
}
|
||||
|
||||
func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdPartyRemote, error) {
|
||||
rtpr := &RelayThirdPartyRemote{
|
||||
messages: make(chan string, 3),
|
||||
}
|
||||
|
||||
var err error
|
||||
rtpr.relay, err = nostr.RelayConnect(ctx, url, nostr.RelayOptions{
|
||||
CustomHandler: func(data string) {
|
||||
envelope := nip77.ParseNegMessage(data)
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
switch env := envelope.(type) {
|
||||
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
|
||||
rtpr.fail(fmt.Errorf("unexpected %s received from relay", env.Label()))
|
||||
return
|
||||
case *nip77.ErrorEnvelope:
|
||||
rtpr.fail(fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason))
|
||||
return
|
||||
case *nip77.MessageEnvelope:
|
||||
rtpr.mu.Lock()
|
||||
failed := rtpr.err != nil
|
||||
rtpr.mu.Unlock()
|
||||
if !failed {
|
||||
rtpr.messages <- env.Message
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rtpr, nil
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendInitialMessage(filter nostr.Filter, msg string) error {
|
||||
msgj, _ := json.Marshal(nip77.OpenEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
Filter: filter,
|
||||
Message: msg,
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendMessage(msg string) error {
|
||||
msgj, _ := json.Marshal(nip77.MessageEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
Message: msg,
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendClose() error {
|
||||
msgj, _ := json.Marshal(nip77.CloseEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
|
||||
if msg, ok := <-rtpr.messages; ok {
|
||||
return msg, nil
|
||||
}
|
||||
// channel closed by fail()
|
||||
if err := rtpr.getErr(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", thirdPartyRemoteEndOfMessages
|
||||
}
|
||||
|
||||
func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.Filter) *ThirdPartyNegentropy {
|
||||
return &ThirdPartyNegentropy{
|
||||
PeerA: peerA,
|
||||
PeerB: peerB,
|
||||
Filter: filter,
|
||||
Deltas: make(chan Delta, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
|
||||
// always close Deltas so the consumer goroutine doesn't hang when we return an error
|
||||
defer close(n.Deltas)
|
||||
|
||||
peerAIds := make(map[nostr.ID]struct{})
|
||||
peerBIds := make(map[nostr.ID]struct{})
|
||||
peerASkippedBounds := make(map[boundKey]struct{})
|
||||
peerBSkippedBounds := make(map[boundKey]struct{})
|
||||
|
||||
// send an empty message to A to start things up
|
||||
initialMsg := createInitialMessage()
|
||||
err := n.PeerA.SendInitialMessage(n.Filter, initialMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSentInitialMessageToB := false
|
||||
|
||||
for {
|
||||
// receive message from A
|
||||
msgA, err := n.PeerA.Receive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgAb, _ := nostr.HexDecodeString(msgA)
|
||||
if len(msgAb) == 1 {
|
||||
break
|
||||
}
|
||||
|
||||
msgToB, err := parseMessageBuildNext(
|
||||
msgA,
|
||||
peerBSkippedBounds,
|
||||
func(id nostr.ID) {
|
||||
if _, exists := peerBIds[id]; exists {
|
||||
delete(peerBIds, id)
|
||||
} else {
|
||||
peerAIds[id] = struct{}{}
|
||||
}
|
||||
},
|
||||
func(boundKey boundKey) {
|
||||
peerASkippedBounds[boundKey] = struct{}{}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// emit deltas from B after receiving message from A
|
||||
for id := range peerBIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
delete(peerBIds, id)
|
||||
}
|
||||
|
||||
if len(msgToB) == 2 {
|
||||
// exit condition (no more messages to send)
|
||||
break
|
||||
}
|
||||
|
||||
// send message to B
|
||||
if hasSentInitialMessageToB {
|
||||
err = n.PeerB.SendMessage(msgToB)
|
||||
} else {
|
||||
err = n.PeerB.SendInitialMessage(n.Filter, msgToB)
|
||||
hasSentInitialMessageToB = true
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// receive message from B
|
||||
msgB, err := n.PeerB.Receive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgBb, _ := nostr.HexDecodeString(msgB)
|
||||
if len(msgBb) == 1 {
|
||||
break
|
||||
}
|
||||
|
||||
msgToA, err := parseMessageBuildNext(
|
||||
msgB,
|
||||
peerASkippedBounds,
|
||||
func(id nostr.ID) {
|
||||
if _, exists := peerAIds[id]; exists {
|
||||
delete(peerAIds, id)
|
||||
} else {
|
||||
peerBIds[id] = struct{}{}
|
||||
}
|
||||
},
|
||||
func(boundKey boundKey) {
|
||||
peerBSkippedBounds[boundKey] = struct{}{}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// emit deltas from A after receiving message from B
|
||||
for id := range peerAIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
delete(peerAIds, id)
|
||||
}
|
||||
|
||||
if len(msgToA) == 2 {
|
||||
// exit condition (no more messages to send)
|
||||
break
|
||||
}
|
||||
|
||||
// send message to A
|
||||
err = n.PeerA.SendMessage(msgToA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// emit remaining deltas before exit
|
||||
for id := range peerAIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
for id := range peerBIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
n.PeerA.SendClose()
|
||||
n.PeerB.SendClose()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createInitialMessage() string {
|
||||
output := bytes.NewBuffer(make([]byte, 0, 64))
|
||||
output.WriteByte(negentropy.ProtocolVersion)
|
||||
|
||||
dummy := negentropy.BoundWriter{}
|
||||
dummy.WriteBound(output, negentropy.InfiniteBound)
|
||||
output.WriteByte(byte(negentropy.FingerprintMode))
|
||||
|
||||
// hardcoded random fingerprint
|
||||
fingerprint := [negentropy.FingerprintSize]byte{
|
||||
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
|
||||
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
|
||||
}
|
||||
output.Write(fingerprint[:])
|
||||
|
||||
return nostr.HexEncodeToString(output.Bytes())
|
||||
}
|
||||
|
||||
func parseMessageBuildNext(
|
||||
msg string,
|
||||
skippedBounds map[boundKey]struct{},
|
||||
idCallback func(id nostr.ID),
|
||||
skipCallback func(boundKey boundKey),
|
||||
) (string, error) {
|
||||
msgb, err := nostr.HexDecodeString(msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
br := &negentropy.BoundReader{}
|
||||
bw := &negentropy.BoundWriter{}
|
||||
|
||||
nextMsg := bytes.NewBuffer(make([]byte, 0, len(msgb)))
|
||||
acc := &storage.Accumulator{} // this will be used for building our own fingerprints and also as a placeholder
|
||||
|
||||
reader := bytes.NewReader(msgb)
|
||||
pv, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pv != negentropy.ProtocolVersion {
|
||||
return "", fmt.Errorf("unsupported protocol version %v", pv)
|
||||
}
|
||||
|
||||
nextMsg.WriteByte(pv)
|
||||
|
||||
for reader.Len() > 0 {
|
||||
bound, err := br.ReadBound(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
modeVal, err := negentropy.ReadVarInt(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := negentropy.Mode(modeVal)
|
||||
|
||||
switch mode {
|
||||
case negentropy.SkipMode:
|
||||
skipCallback(getBoundKey(bound))
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.SkipMode))
|
||||
}
|
||||
|
||||
case negentropy.FingerprintMode:
|
||||
_, err = reader.Read(acc.Buf[0:negentropy.FingerprintSize] /* use this buffer as a dummy */)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
|
||||
}
|
||||
case negentropy.IdListMode:
|
||||
// when receiving an idlist we will never send this bound again to this peer
|
||||
skipCallback(getBoundKey(bound))
|
||||
|
||||
// and instead of sending these ids to the other peer we'll send a fingerprint
|
||||
acc.Reset()
|
||||
|
||||
numIds, err := negentropy.ReadVarInt(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for range numIds {
|
||||
id := nostr.ID{}
|
||||
|
||||
_, err = reader.Read(id[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
idCallback(id)
|
||||
|
||||
acc.AddBytes(id[:])
|
||||
}
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
fingerprint := acc.GetFingerprint(numIds)
|
||||
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(fingerprint[:])
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown mode %v", mode)
|
||||
}
|
||||
}
|
||||
|
||||
return nostr.HexEncodeToString(nextMsg.Bytes()), nil
|
||||
}
|
||||
321
thread_helpers.go
Normal file
321
thread_helpers.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip22"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
|
||||
filterTags := nostr.TagMap{
|
||||
"E": []string{discussionID.Hex()},
|
||||
}
|
||||
for key, values := range extraTags {
|
||||
filterTags[key] = values
|
||||
}
|
||||
|
||||
comments := make([]nostr.RelayEvent, 0, 15)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Tags: filterTags,
|
||||
Limit: 500,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
|
||||
comments = append(comments, ie)
|
||||
}
|
||||
|
||||
slices.SortFunc(comments, nostr.CompareRelayEvent)
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
func showThreadWithComments(
|
||||
ctx context.Context,
|
||||
relays []string,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
extraTags nostr.TagMap,
|
||||
) error {
|
||||
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printThreadMetadata(ctx, os.Stdout, evt, status, true)
|
||||
stdout("")
|
||||
stdout(evt.Content)
|
||||
|
||||
if len(comments) > 0 {
|
||||
stdout("")
|
||||
stdout(color.CyanString("comments:"))
|
||||
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printThreadedComments(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
comments []nostr.RelayEvent,
|
||||
discussionID nostr.ID,
|
||||
withColor bool,
|
||||
) {
|
||||
byID := make(map[nostr.ID]struct{}, len(comments)+1)
|
||||
byID[discussionID] = struct{}{}
|
||||
for _, c := range comments {
|
||||
byID[c.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// preload metadata from everybody
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
|
||||
for _, c := range comments {
|
||||
wg.Go(func() {
|
||||
sys.FetchProfileMetadata(ctx, c.PubKey)
|
||||
})
|
||||
|
||||
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := byID[parent.ID]; ok {
|
||||
children[parent.ID] = append(children[parent.ID], c)
|
||||
}
|
||||
}
|
||||
|
||||
for parent := range children {
|
||||
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var render func(parent nostr.ID, depth int)
|
||||
render = func(parent nostr.ID, depth int) {
|
||||
for _, c := range children[parent] {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
author := authorPreview(ctx, c.PubKey)
|
||||
created := c.CreatedAt.Time().Format(time.DateTime)
|
||||
|
||||
if withColor {
|
||||
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
||||
} else {
|
||||
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(c.Content, "\n") {
|
||||
fmt.Fprintln(w, indent+" "+line)
|
||||
}
|
||||
fmt.Fprintln(w, indent+"")
|
||||
|
||||
render(c.ID, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
render(discussionID, 0)
|
||||
}
|
||||
|
||||
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if prefix == "" {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
|
||||
}
|
||||
|
||||
matchCount := 0
|
||||
matched := nostr.RelayEvent{}
|
||||
for _, evt := range events {
|
||||
if strings.HasPrefix(evt.ID.Hex(), prefix) {
|
||||
matched = evt
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
|
||||
if matchCount == 0 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func printThreadMetadata(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
withColors bool,
|
||||
) {
|
||||
label := func(s string) string { return s }
|
||||
value := func(s string) string { return s }
|
||||
statusValue := func(s string) string { return s }
|
||||
if withColors {
|
||||
label = func(s string) string { return color.CyanString(s) }
|
||||
value = func(s string) string { return color.HiWhiteString(s) }
|
||||
statusValue = colorizeGitStatus
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
|
||||
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
|
||||
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
|
||||
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
|
||||
if status != "" {
|
||||
fmt.Fprintln(w, label("status:"), statusValue(status))
|
||||
}
|
||||
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
||||
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
|
||||
fmt.Fprintln(w, label("title:"), value(title[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
}
|
||||
}
|
||||
|
||||
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
|
||||
currentParent := discussion
|
||||
selectedParent := nostr.ZeroID
|
||||
inComments := false
|
||||
|
||||
replyb := strings.Builder{}
|
||||
for _, line := range strings.Split(edited, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
|
||||
inComments = false
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
if replyb.Len() == 0 && line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#>") {
|
||||
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
||||
if quoted == "comments:" {
|
||||
inComments = true
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
// keep track of which comment the reply body shows up below of
|
||||
// so we can assign it as a reply to that specifically
|
||||
fields := strings.Fields(quoted)
|
||||
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
|
||||
currId := fields[0][1 : len(fields[0])-1]
|
||||
for _, comment := range comments {
|
||||
if strings.HasPrefix(comment.ID.Hex(), currId) {
|
||||
currentParent = comment
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// if we reach here this is a line for the reply input from the user
|
||||
replyb.WriteString(line)
|
||||
replyb.WriteByte('\n')
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
|
||||
}
|
||||
|
||||
selectedParent = currentParent.ID
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(replyb.String())
|
||||
if content == "" {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
|
||||
}
|
||||
|
||||
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
|
||||
return content, discussion, nil
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.ID == selectedParent {
|
||||
return content, comment, nil
|
||||
}
|
||||
}
|
||||
|
||||
panic("selected reply parent not found (this never happens)")
|
||||
}
|
||||
|
||||
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
||||
lines := make([]string, 0, len(headerLines)+3)
|
||||
for _, line := range headerLines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "# "+strings.TrimSpace(line))
|
||||
}
|
||||
lines = append(lines,
|
||||
"# write your reply here.",
|
||||
"# lines starting with '#' are ignored.",
|
||||
"",
|
||||
)
|
||||
|
||||
appender := &lineAppender{lines, "#> "}
|
||||
|
||||
printThreadMetadata(ctx, appender, discussion, "", false)
|
||||
|
||||
for _, line := range strings.Split(discussion.Content, "\n") {
|
||||
appender.lines = append(appender.lines, "#> "+line)
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
||||
printThreadedComments(ctx, appender, comments, discussion.ID, false)
|
||||
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
|
||||
}
|
||||
|
||||
return strings.Join(appender.lines, "\n")
|
||||
}
|
||||
|
||||
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, "", "", err
|
||||
}
|
||||
|
||||
meta := sys.FetchProfileMetadata(ctx, pk)
|
||||
return pk, meta.ShortName(), meta.NpubShort(), nil
|
||||
}
|
||||
|
||||
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
|
||||
meta := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
if meta.Name != "" {
|
||||
return meta.ShortName() + " (" + meta.NpubShort() + ")"
|
||||
}
|
||||
return meta.NpubShort()
|
||||
}
|
||||
|
||||
type lineAppender struct {
|
||||
lines []string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (l *lineAppender) Write(b []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
||||
line = strings.TrimRight(line, " ")
|
||||
l.lines = append(l.lines, l.prefix+line)
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
72
validate.go
Normal file
72
validate.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var validateCmd = &cli.Command{
|
||||
Name: "validate",
|
||||
Usage: "validates events against the provided RoK YAML schema",
|
||||
Description: `takes a URL to a YAML schema in the same format as that of https://github.com/nostr-protocol/registry-of-kinds (defaults to that one) and validates the event tags and content against it, according to its kind.
|
||||
|
||||
example:
|
||||
nak event -k 1 -p not_a_pubkey | nak validate
|
||||
>> schema validation failed: tag[0][1]: invalid pubkey value 'not_a_pubkey' at tag 'p', index 1: pubkey should be 64-char hex, got 'not_a_pubkey'
|
||||
`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
sch, err := getSchema()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validator := schema.NewValidatorFromSchema(sch)
|
||||
|
||||
handleEvent := func(stdinEvent string) error {
|
||||
evt := nostr.Event{}
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
return fmt.Errorf("invalid event JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := validator.ValidateEvent(evt); err != nil {
|
||||
return fmt.Errorf("schema validation failed: %w", err)
|
||||
}
|
||||
|
||||
stdout(evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
if stdinEvent == "{}" && !isPiped() {
|
||||
// blank sentinel from getJsonsOrBlank(), use the arguments instead
|
||||
for _, arg := range c.Args().Slice() {
|
||||
if err := handleEvent(arg); err != nil {
|
||||
ctx = lineProcessingError(ctx, "%s", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handleEvent(stdinEvent); err != nil {
|
||||
ctx = lineProcessingError(ctx, "%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
46
verify.go
46
verify.go
@@ -3,37 +3,61 @@ package main
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fiatjaf/cli/v3"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var verify = &cli.Command{
|
||||
Name: "verify",
|
||||
Usage: "checks the hash and signature of an event given through stdin",
|
||||
Usage: "checks the hash and signature of an event given through stdin or as the first argument",
|
||||
Description: `example:
|
||||
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak verify
|
||||
|
||||
it outputs nothing if the verification is successful.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for stdinEvent := range getStdinLinesOrArguments(c.Args()) {
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
evt := nostr.Event{}
|
||||
if stdinEvent != "" {
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||
if stdinEvent == "{}" && !isPiped() {
|
||||
// blank sentinel from getJsonsOrBlank(), use the argument instead
|
||||
stdinEvent = c.Args().First()
|
||||
if stdinEvent == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if evt.GetID() != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||
logverbose("%s\n", color.RedString("<>: invalid event."))
|
||||
continue
|
||||
}
|
||||
|
||||
if ok, err := evt.CheckSignature(); !ok {
|
||||
ctx = lineProcessingError(ctx, "invalid signature: %v", err)
|
||||
impliedID := evt.GetID()
|
||||
idsMatch := impliedID == evt.ID
|
||||
logverbose(
|
||||
"%s\n%s %s\n%s %s\n%s %s\n%s %s\n%s %s\n",
|
||||
color.CyanString("verifying event:"),
|
||||
color.BlueString(" event: "), stdinEvent,
|
||||
color.BlueString(" given id: "), color.YellowString("%s", evt.ID),
|
||||
color.BlueString(" serialized:"), string(evt.Serialize()),
|
||||
color.BlueString(" implied id:"), color.YellowString("%s", impliedID),
|
||||
color.BlueString(" ids match: "), color.New(map[bool]color.Attribute{true: color.FgGreen, false: color.FgRed}[idsMatch]).Sprint(idsMatch),
|
||||
)
|
||||
|
||||
if impliedID != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", impliedID, evt.ID)
|
||||
logverbose("%s\n", color.RedString("invalid id: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
if !evt.VerifySignature() {
|
||||
ctx = lineProcessingError(ctx, "invalid signature")
|
||||
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
567
wallet.go
Normal file
567
wallet.go
Normal file
@@ -0,0 +1,567 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip60"
|
||||
"fiatjaf.com/nostr/nip61"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(), error) {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
relays := sys.FetchOutboxRelays(ctx, pk, 3)
|
||||
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
|
||||
if w == nil {
|
||||
return nil, nil, fmt.Errorf("error loading wallet")
|
||||
}
|
||||
|
||||
w.Processed = func(evt nostr.Event, err error) {
|
||||
if err == nil {
|
||||
logverbose("processed event %s\n", evt)
|
||||
|
||||
if c.Bool("stream") {
|
||||
// after EOSE log updates and the new balance
|
||||
select {
|
||||
case <-w.Stable:
|
||||
switch evt.Kind {
|
||||
case 5:
|
||||
log("- token deleted\n")
|
||||
case 7375:
|
||||
log("- token added\n")
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
log(" balance: %d\n", w.Balance())
|
||||
default:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("error processing event %s: %s\n", evt, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.PublishUpdate = func(event nostr.Event, deleted, received, change *nip60.Token, isHistory bool) {
|
||||
desc := "wallet"
|
||||
if received != nil {
|
||||
mint, _ := strings.CutPrefix(received.Mint, "https://")
|
||||
desc = fmt.Sprintf("received from %s with %d proofs totalling %d",
|
||||
mint, len(received.Proofs), received.Proofs.Amount())
|
||||
} else if change != nil {
|
||||
mint, _ := strings.CutPrefix(change.Mint, "https://")
|
||||
desc = fmt.Sprintf("change from %s with %d proofs totalling %d",
|
||||
mint, len(change.Proofs), change.Proofs.Amount())
|
||||
} else if deleted != nil {
|
||||
mint, _ := strings.CutPrefix(deleted.Mint, "https://")
|
||||
desc = fmt.Sprintf("deleting a used token from %s with %d proofs totalling %d",
|
||||
mint, len(deleted.Proofs), deleted.Proofs.Amount())
|
||||
} else if isHistory {
|
||||
desc = "history entry"
|
||||
}
|
||||
|
||||
log("- saving kind:%d event (%s)... ", event.Kind, desc)
|
||||
first := true
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||
} else {
|
||||
log("%s: ok", colors.successf(cleanUrl))
|
||||
}
|
||||
}
|
||||
log("\n")
|
||||
}
|
||||
|
||||
<-w.Stable
|
||||
|
||||
return w, func() {
|
||||
w.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
var wallet = &cli.Command{
|
||||
Name: "wallet",
|
||||
Usage: "displays the current wallet balance",
|
||||
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep listening for wallet-related events and logging them",
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log("balance: ")
|
||||
stdout(w.Balance())
|
||||
|
||||
if c.Bool("stream") {
|
||||
<-ctx.Done() // this will hang forever
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "mints",
|
||||
Usage: "lists, adds or remove default mints from the wallet",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, url := range w.Mints {
|
||||
if _, host, ok := strings.Cut(url, "://"); ok {
|
||||
stdout(host)
|
||||
} else {
|
||||
stdout(url)
|
||||
}
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "<mint>...",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.AddMint(ctx, c.Args().Slice()...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "<mint>...",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.RemoveMint(ctx, c.Args().Slice()...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tokens",
|
||||
Usage: "lists existing tokens with their mints and aggregated amounts",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, token := range w.Tokens {
|
||||
_, mintHost, _ := strings.Cut(token.Mint, "://")
|
||||
if mintHost == "" {
|
||||
mintHost = token.Mint
|
||||
}
|
||||
stdout(token.ID(), token.Proofs.Amount(), mintHost)
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "drop",
|
||||
Usage: "deletes a token from the wallet",
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "<id>...",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
ids := c.Args().Slice()
|
||||
if len(ids) == 0 {
|
||||
return fmt.Errorf("no token ids specified")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, token := range w.Tokens {
|
||||
if slices.Contains(ids, token.ID()) {
|
||||
w.DropToken(ctx, token.ID())
|
||||
_, mintHost, _ := strings.Cut(token.Mint, "://")
|
||||
if mintHost == "" {
|
||||
mintHost = token.Mint
|
||||
}
|
||||
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
|
||||
}
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "receive",
|
||||
Usage: "takes a cashu token string as an argument and adds it to the wallet",
|
||||
ArgsUsage: "<token>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "mint",
|
||||
Usage: "mint to swap the token into",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet receive <token>")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proofs, mint, err := nip60.GetProofsAndMint(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.Receive(ctx, proofs, mint, nip60.ReceiveOptions{
|
||||
IntoMint: c.StringSlice("mint"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send",
|
||||
Usage: "prints a cashu token with the given amount for sending to someone else",
|
||||
ArgsUsage: "<amount>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "mint",
|
||||
Usage: "send from a specific mint",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet send <amount>")
|
||||
}
|
||||
amount, err := strconv.ParseUint(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("amount '%s' is invalid", args[0])
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sourceMint string
|
||||
if mint := c.String("mint"); mint != "" {
|
||||
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||
}
|
||||
proofs, mint, err := w.SendInternal(ctx, amount, nip60.SendOptions{
|
||||
SpecificSourceMint: sourceMint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdout(nip60.MakeTokenString(proofs, mint))
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pay",
|
||||
Usage: "pays a bolt11 lightning invoice and outputs the preimage",
|
||||
ArgsUsage: "<invoice>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "mint",
|
||||
Usage: "pay from a specific mint",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet pay <invoice>")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sourceMint string
|
||||
if mint := c.String("mint"); mint != "" {
|
||||
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||
}
|
||||
|
||||
preimage, err := w.PayBolt11(ctx, args[0], nip60.PayOptions{
|
||||
FromMint: sourceMint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdout(preimage)
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "nutzap",
|
||||
Usage: "sends a nip61 nutzap to one or more Nostr profiles and/or events",
|
||||
ArgsUsage: "<amount> <target>",
|
||||
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Usage: "npub, nprofile, nevent or hex pubkey",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mint",
|
||||
Usage: "send from a specific mint",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "message",
|
||||
Usage: "attach a message to the nutzap",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() < 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseInt(c.Args().First(), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid amount '%s': %w", c.Args().First(), err)
|
||||
}
|
||||
|
||||
target := c.String("target")
|
||||
var pm sdk.ProfileMetadata
|
||||
|
||||
var evt *nostr.Event
|
||||
var eventId nostr.ID
|
||||
|
||||
if strings.HasPrefix(target, "nevent1") {
|
||||
evt, _, err = sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventId = evt.ID
|
||||
pm = sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
} else {
|
||||
pm, err = sys.FetchProfileFromInput(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log("sending %d sat to '%s' (%s)", amount, pm.ShortName(), pm.Npub())
|
||||
|
||||
var sourceMint string
|
||||
if mint := c.String("mint"); mint != "" {
|
||||
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
|
||||
}
|
||||
|
||||
kr, _, _ := gatherKeyerFromArguments(ctx, c)
|
||||
results, err := nip61.SendNutzap(
|
||||
ctx,
|
||||
kr,
|
||||
w,
|
||||
sys.Pool,
|
||||
uint64(amount),
|
||||
pm.PubKey,
|
||||
sys.FetchWriteRelays(ctx, pm.PubKey),
|
||||
nip61.NutzapOptions{
|
||||
Message: c.String("message"),
|
||||
SendToRelays: sys.FetchInboxRelays(ctx, pm.PubKey, 3),
|
||||
EventID: eventId,
|
||||
SpecificSourceMint: sourceMint,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log("- publishing nutzap... ")
|
||||
first := true
|
||||
for res := range results {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
if res.Error != nil {
|
||||
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||
} else {
|
||||
log("%s: ok", colors.successf(cleanUrl))
|
||||
}
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "setup",
|
||||
Usage: "setup your wallet private key and kind:10019 event for receiving nutzaps",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "mint",
|
||||
Usage: "mints to receive nutzaps in",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "private-key",
|
||||
Usage: "private key used for receiving nutzaps",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "forces replacement of private-key",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w.PrivateKey == nil {
|
||||
if sk := c.String("private-key"); sk != "" {
|
||||
if err := w.SetPrivateKey(ctx, sk); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("missing --private-key")
|
||||
}
|
||||
} else if sk := c.String("private-key"); sk != "" {
|
||||
if !c.Bool("force") {
|
||||
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
|
||||
}
|
||||
if err := w.SetPrivateKey(ctx, sk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
kr, _, _ := gatherKeyerFromArguments(ctx, c)
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
relays := sys.FetchWriteRelays(ctx, pk)
|
||||
|
||||
info := nip61.Info{}
|
||||
ie := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10019},
|
||||
Authors: []nostr.PubKey{pk},
|
||||
Limit: 1,
|
||||
}, nostr.SubscriptionOptions{})
|
||||
if ie != nil {
|
||||
info.ParseEvent(ie.Event)
|
||||
}
|
||||
|
||||
if mints := c.StringSlice("mint"); len(mints) > 0 {
|
||||
info.Mints = mints
|
||||
} else if len(info.Mints) == 0 {
|
||||
info.Mints = w.Mints
|
||||
}
|
||||
if len(info.Mints) == 0 {
|
||||
return fmt.Errorf("missing --mint")
|
||||
}
|
||||
|
||||
// the pubkey tag is mandatory, without it no one can send us nutzaps
|
||||
info.PublicKey = nostr.PubKey(w.PublicKey.SerializeCompressed()[1:])
|
||||
|
||||
evt := nostr.Event{}
|
||||
if err := info.ToEvent(ctx, kr, &evt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdout(evt)
|
||||
log("- saving kind:10019 event... ")
|
||||
first := true
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", colors.errorf(cleanUrl), res.Error)
|
||||
} else {
|
||||
log("%s: ok", colors.successf(cleanUrl))
|
||||
}
|
||||
}
|
||||
|
||||
closew()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
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]
|
||||
repository: https://github.com/fiatjaf/nak
|
||||
assets:
|
||||
- nak-v\d+\.\d+\.\d+-darwin-arm64
|
||||
- nak-v\d+\.\d+\.\d+-linux-amd64
|
||||
- nak-v\d+\.\d+\.\d+-linux-arm64
|
||||
remote_metadata:
|
||||
- github
|
||||
|
||||
Reference in New Issue
Block a user