mirror of
https://github.com/fiatjaf/nak.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
249 Commits
v0.17.8
...
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 |
132
.github/workflows/release-cli.yml
vendored
132
.github/workflows/release-cli.yml
vendored
@@ -4,123 +4,49 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
make-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-release@latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
build-all-for-all:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- make-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [linux, freebsd, windows]
|
||||
goos: [linux, freebsd, darwin, windows]
|
||||
goarch: [amd64, arm64, riscv64]
|
||||
exclude:
|
||||
- goarch: arm64
|
||||
goos: windows
|
||||
- goarch: riscv64
|
||||
goos: windows
|
||||
- goarch: arm64
|
||||
goos: freebsd
|
||||
- goarch: riscv64
|
||||
goos: freebsd
|
||||
- goarch: arm64
|
||||
goos: windows
|
||||
- goarch: riscv64
|
||||
goos: windows
|
||||
- goarch: riscv64
|
||||
goos: darwin
|
||||
- goarch: arm64
|
||||
goos: freebsd
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Install FUSE dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse-dev
|
||||
- name: Install cross-compilation tools for ARM64
|
||||
if: matrix.goarch == 'arm64' && matrix.goos == 'linux'
|
||||
run: |
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
- name: Install cross-compilation tools for RISC-V
|
||||
if: matrix.goarch == 'riscv64' && matrix.goos == 'linux'
|
||||
run: |
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu
|
||||
- name: Install FreeBSD SDK
|
||||
if: matrix.goos == 'freebsd'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld binutils-riscv64-unknown-elf 2>/dev/null || true
|
||||
mkdir -p /tmp/freebsd-sdk
|
||||
cd /tmp/freebsd-sdk
|
||||
# Determine download path based on architecture
|
||||
case "${{ matrix.goarch }}" in
|
||||
arm64) DLPATH="arm64" ;;
|
||||
riscv64) DLPATH="riscv" ;;
|
||||
*) DLPATH="amd64" ;;
|
||||
esac
|
||||
wget -q "https://download.freebsd.org/releases/${DLPATH}/14.3-RELEASE/base.txz" && tar -xf base.txz
|
||||
# Download and extract libfuse source
|
||||
mkdir -p libfuse && cd libfuse
|
||||
wget -q https://github.com/libfuse/libfuse/releases/download/fuse-2.9.9/fuse-2.9.9.tar.gz
|
||||
tar -xzf fuse-2.9.9.tar.gz --strip-components=1
|
||||
mkdir -p /tmp/freebsd-sdk/usr/include
|
||||
cp include/fuse.h /tmp/freebsd-sdk/usr/include/
|
||||
cp include/fuse_*.h /tmp/freebsd-sdk/usr/include/ 2>/dev/null || true
|
||||
cd /tmp/freebsd-sdk
|
||||
- name: Set cross-compiler
|
||||
id: set-cc
|
||||
run: |
|
||||
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.goarch }}" = "riscv64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
echo "CC=riscv64-linux-gnu-gcc" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.goos }}" = "freebsd" ]; then
|
||||
TRIPLE="x86_64-unknown-freebsd14.3"
|
||||
[ "${{ matrix.goarch }}" = "arm64" ] && TRIPLE="aarch64-unknown-freebsd14.3"
|
||||
[ "${{ matrix.goarch }}" = "riscv64" ] && TRIPLE="riscv64-unknown-freebsd14.3"
|
||||
echo "CC=clang --target=$TRIPLE --sysroot=/tmp/freebsd-sdk" >> $GITHUB_ENV
|
||||
echo "CGO_CFLAGS=-isystem /tmp/freebsd-sdk/usr/include --target=$TRIPLE" >> $GITHUB_ENV
|
||||
echo "CGO_LDFLAGS=-L/tmp/freebsd-sdk/usr/lib -L/tmp/freebsd-sdk/lib -L/tmp/freebsd-sdk/usr/lib64 --target=$TRIPLE" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: ${{ matrix.goos == 'windows' && '0' || '1' }}
|
||||
run: |
|
||||
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
- name: Upload Release Asset
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
build-darwin:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Install macFUSE
|
||||
run: brew install --cask macfuse
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: '1'
|
||||
run: |
|
||||
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
||||
- name: Upload Release Asset
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
ldflags: -X main.version=${{ github.ref_name }}
|
||||
overwrite: true
|
||||
md5sum: false
|
||||
sha256sum: false
|
||||
compress_assets: false
|
||||
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# install git and ca-certificates (needed for fetching dependencies)
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
RUN apk add --no-cache git ca-certificates gcc musl-dev
|
||||
|
||||
# set working directory
|
||||
WORKDIR /app
|
||||
@@ -19,7 +19,7 @@ COPY . .
|
||||
# build the application
|
||||
# use cgo_enabled=0 to create a static binary
|
||||
# use -ldflags to strip debug info and reduce binary size
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o nak .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
|
||||
|
||||
# runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
133
README.md
133
README.md
@@ -1,9 +1,19 @@
|
||||
# nak, the nostr army knife
|
||||
|
||||
install with `go install github.com/fiatjaf/nak@latest` or
|
||||
[download a binary](https://github.com/fiatjaf/nak/releases).
|
||||
install with this one-liner:
|
||||
|
||||
or get the source with `git clone https://github.com/fiatjaf/nak` then install with `go install` or run with docker using `docker build -t nak . && docker run nak event`.
|
||||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
|
||||
```
|
||||
|
||||
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
|
||||
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
|
||||
- or get the source with `git clone https://github.com/fiatjaf/nak` then
|
||||
- install with `go install`;
|
||||
- or run with docker using `docker build -t nak . && docker run nak event`.
|
||||
- or install with `brew install nak` if you use **macOS Homebrew**.
|
||||
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
|
||||
- or install with `nix-env --install nak` if you use **Nix**.
|
||||
|
||||
## what can you do with it?
|
||||
|
||||
@@ -34,7 +44,7 @@ publishing to wss://relay.damus.io... success.
|
||||
"Activando modo zen…\n\n#GM #Nostr #Hispano"
|
||||
```
|
||||
|
||||
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
|
||||
### decode a NIP-19 note1 code, add a relay hint, encode it back to nevent1
|
||||
```shell
|
||||
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
|
||||
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
|
||||
@@ -97,7 +107,7 @@ ok.
|
||||
|
||||
### sign an event collaboratively with multiple parties using musig2
|
||||
```shell
|
||||
~> nak event --sec 1234 -k 1 -c 'hello from a combined key' --musig 2
|
||||
~> nak event --sec 1234 -k 'text note' -c 'hello from a combined key' --musig 2
|
||||
the following code should be saved secretly until the next step an included with --musig-nonce-secret:
|
||||
QebOT03ERmV7km22CqEqBPFmzAkgxQzGGbR7Si8yIZCBrd1N9A3LKwGLO71kbgXZ9EYFKpjiwun4u0mj5Tq6vwM3pK7x+EI8oHbkt9majKv/QN24Ix8qnwEIHxXX+mXBug==
|
||||
|
||||
@@ -135,6 +145,33 @@ type the password to decrypt your secret key: **********
|
||||
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
|
||||
```
|
||||
|
||||
### display profile information for any npub, nip05 or hex key
|
||||
|
||||
```shell
|
||||
~> nak profile f7z.io
|
||||
pubkey (hex): fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52
|
||||
npub: npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
|
||||
profile uri: nostr:nprofile1qqs04xzt6ldm9qhs0ctw0t58kf4z57umjzmjg6jywu0seadwtqqc75spr9mhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5q3qamnwvaz7te3xsczue3h0ghxjmcppemhxue69uhhytnxxaazu6t0axr23s
|
||||
name: PABLOF7z
|
||||
display_name: PABLOF7z
|
||||
about: Magical Other Stuff Maximalist.
|
||||
picture: https://m.primal.net/KwlG.jpg
|
||||
banner: https://24242.io/1dc3a4a8bb2626551d4cc91e93f5b2e6ef87b1931b9db9bf4ac72dfe61c7a45b.png
|
||||
website: https://pablof7z.com
|
||||
nip05: _@f7z.io (verified)
|
||||
lud16: pablof7z@primal.net
|
||||
relays:
|
||||
wss://relay.damus.io (read)
|
||||
wss://relay.primal.net (read)
|
||||
wss://pyramid.fiatjaf.com (write)
|
||||
wss://140.f7z.io (write)
|
||||
wss://r.f7z.io (read/write)
|
||||
follows: 1051
|
||||
~>
|
||||
~> nak profile f7z.io --name
|
||||
PABLOF7z
|
||||
```
|
||||
|
||||
### sign an event using a bunker provider (amber, promenade etc)
|
||||
```shell
|
||||
~> export NOSTR_CLIENT_KEY="$(nak key generate)"
|
||||
@@ -200,6 +237,18 @@ or give it a named profile:
|
||||
~> nak bunker --profile myself ...
|
||||
```
|
||||
|
||||
### send a `nostrconnect://` client URI to a running bunker
|
||||
|
||||
```shell
|
||||
~> nak bunker connect 'nostrconnect://...'
|
||||
```
|
||||
|
||||
or, if you're using a persisted profile
|
||||
|
||||
```shell
|
||||
~> nak bunker connect --profile default 'nostrconnect://...'
|
||||
```
|
||||
|
||||
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
|
||||
```shell
|
||||
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
|
||||
@@ -227,7 +276,7 @@ or give it a named profile:
|
||||
• events stored: 4, subscriptions opened: 1
|
||||
```
|
||||
|
||||
### enable negentropy (nip77) support in your development relay
|
||||
### enable negentropy (NIP-77) support in your development relay
|
||||
```shell
|
||||
~> nak serve --negentropy
|
||||
```
|
||||
@@ -262,6 +311,28 @@ type the password to decrypt your secret key: ********
|
||||
~> nak req -i 412f2d3e73acc312942c055ac2a695dc60bf58ff97e06689a8a79e97796c4cdb relay.westernbtc.com | jq -r .content > ~/.jq
|
||||
```
|
||||
|
||||
### filter and transform events with the built-in `--jq` helpers
|
||||
|
||||
extra-filter events with `select()` based on tags:
|
||||
|
||||
```shell
|
||||
~> nak req -k 1 -l 10 relay.damus.io --jq 'select(has("t"))'
|
||||
```
|
||||
|
||||
extract a tag value:
|
||||
|
||||
```shell
|
||||
~> nak req -k 1 -l 10 -t t=gm relay.damus.io --jq 'value("t")'
|
||||
```
|
||||
|
||||
make an event easier to read by removing fields and adding human-readable time:
|
||||
|
||||
```shell
|
||||
~> nak req -k 1 -l 5 relay.damus.io --jq '. + { d: datetime } | del(.sig, .created_at)'
|
||||
```
|
||||
|
||||
you can do these same things by piping to standalone `jq`, take the functions in https://github.com/fiatjaf/nak/blob/master/jq.go#L10 and add them to your `~/.jq` file.
|
||||
|
||||
### watch a NIP-53 livestream (zap.stream, amethyst, shosho etc)
|
||||
```shell
|
||||
~> # this requires the jq utils from the step above
|
||||
@@ -321,7 +392,7 @@ echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn6
|
||||
|
||||
### record and publish an audio note (yakbak, nostur etc) signed from a bunker
|
||||
```shell
|
||||
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 1222 --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
|
||||
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 'voice message' --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
|
||||
```
|
||||
|
||||
### gift-wrap an event to a recipient and publish it somewhere
|
||||
@@ -331,7 +402,7 @@ ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blos
|
||||
|
||||
### download a gift-wrap event and unwrap it
|
||||
```shell
|
||||
~> nak req -p <my-public-key> -k 1059 relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
|
||||
~> nak req -p <my-public-key> -k 'giftwrap' relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
|
||||
```
|
||||
|
||||
### sync events between two relays using negentropy
|
||||
@@ -427,6 +498,7 @@ gitnostr.com... ok.
|
||||
```shell
|
||||
~> nak git clone
|
||||
~> nak git init
|
||||
~> nak git status
|
||||
~> nak git sync
|
||||
~> nak git fetch
|
||||
~> nak git pull
|
||||
@@ -441,3 +513,48 @@ gitnostr.com... ok.
|
||||
1a851afaa70a26faa82c5b4422ce967c07e278efc56a1413b9719b662f86551a
|
||||
8031621a54b2502f5bd4dbb87c971c0a69675d252a64d69e22224f3aee6dd2b2
|
||||
```
|
||||
|
||||
### interact with a NIP-29 group
|
||||
```shell
|
||||
~> nak group info "<relay>'<id>"
|
||||
~> nak group admin "<relay>'<id>"
|
||||
~> nak group chat "<relay>'<id>"
|
||||
~> nak group chat send "<relay>'<id>" "<message>"
|
||||
```
|
||||
|
||||
### figure out what is a given kind
|
||||
```shell
|
||||
~> nak kind 10050 | jq .description
|
||||
"Relay list to receive DMs"
|
||||
~> nak kind 'fav relays' | jq
|
||||
{
|
||||
"kind": 10012,
|
||||
"description": "Favorite relays list",
|
||||
"in_use": true,
|
||||
"content": {
|
||||
"type": "free"
|
||||
},
|
||||
"multiple": [
|
||||
"relay"
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "relay",
|
||||
"next": {
|
||||
"type": "relay",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a",
|
||||
"next": {
|
||||
"type": "addr",
|
||||
"required": true,
|
||||
"next": {
|
||||
"type": "relay"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
34
admin.go
34
admin.go
@@ -25,7 +25,6 @@ var admin = &cli.Command{
|
||||
nak admin changerelayname myrelay.com --name "My Relay"`,
|
||||
ArgsUsage: "<relay-url>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: defaultKeyFlags,
|
||||
Commands: (func() []*cli.Command {
|
||||
methods := []struct {
|
||||
method string
|
||||
@@ -48,6 +47,17 @@ var admin = &cli.Command{
|
||||
{"blockip", []string{"ip", "reason"}},
|
||||
{"unblockip", []string{"ip", "reason"}},
|
||||
{"listblockedips", nil},
|
||||
{"listallowedevents", nil},
|
||||
{"listdisallowedkinds", nil},
|
||||
{"supportedmethods", nil},
|
||||
{"stats", nil},
|
||||
{"grantadmin", []string{"pubkey", "methods"}},
|
||||
{"revokeadmin", []string{"pubkey", "methods"}},
|
||||
{"createrole", []string{"role_id", "label", "description", "color", "order"}},
|
||||
{"editrole", []string{"role_id", "label", "description", "color", "order"}},
|
||||
{"deleterole", []string{"role_id"}},
|
||||
{"assignrole", []string{"pubkey", "role_id"}},
|
||||
{"unassignrole", []string{"pubkey", "role_id"}},
|
||||
}
|
||||
|
||||
commands := make([]*cli.Command, 0, len(methods))
|
||||
@@ -122,6 +132,7 @@ var admin = &cli.Command{
|
||||
continue
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
log("failed to read response: %s\n", err)
|
||||
continue
|
||||
@@ -132,7 +143,7 @@ var admin = &cli.Command{
|
||||
if len(bodyPrintable) > 300 {
|
||||
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||
}
|
||||
log(bodyPrintable)
|
||||
log("%s", bodyPrintable)
|
||||
continue
|
||||
}
|
||||
var response nip86.Response
|
||||
@@ -142,10 +153,9 @@ var admin = &cli.Command{
|
||||
if len(bodyPrintable) > 300 {
|
||||
bodyPrintable = bodyPrintable[0:297] + "..."
|
||||
}
|
||||
log(bodyPrintable)
|
||||
log("%s", bodyPrintable)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// print the result
|
||||
log("\n")
|
||||
@@ -167,10 +177,16 @@ var admin = &cli.Command{
|
||||
func declareFlag(argName string) cli.Flag {
|
||||
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
|
||||
switch argName {
|
||||
case "kind":
|
||||
case "pubkey":
|
||||
return &PubKeyFlag{Name: argName, Usage: usage}
|
||||
case "id":
|
||||
return &IDFlag{Name: argName, Usage: usage}
|
||||
case "kind", "order", "color":
|
||||
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
|
||||
case "reason":
|
||||
return &cli.StringFlag{Name: argName, Usage: usage}
|
||||
case "methods":
|
||||
return &cli.StringSliceFlag{Name: argName, Required: true, Usage: usage}
|
||||
default:
|
||||
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
|
||||
}
|
||||
@@ -178,8 +194,14 @@ func declareFlag(argName string) cli.Flag {
|
||||
|
||||
func getArgument(c *cli.Command, argName string) any {
|
||||
switch argName {
|
||||
case "kind":
|
||||
case "pubkey":
|
||||
return getPubKey(c, argName)
|
||||
case "id":
|
||||
return getID(c, argName)
|
||||
case "kind", "order", "color":
|
||||
return c.Int(argName)
|
||||
case "methods":
|
||||
return c.StringSlice(argName)
|
||||
default:
|
||||
return c.String(argName)
|
||||
}
|
||||
|
||||
233
blossom.go
233
blossom.go
@@ -3,15 +3,12 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -23,8 +20,8 @@ var blossomCmd = &cli.Command{
|
||||
UseShortOptionHandling: true,
|
||||
Usage: "an army knife for blossom things",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "the hostname of the target mediaserver",
|
||||
@@ -46,7 +43,11 @@ var blossomCmd = &cli.Command{
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
|
||||
}
|
||||
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
|
||||
} else {
|
||||
var err error
|
||||
client, err = getBlossomClient(ctx, c)
|
||||
@@ -74,11 +75,17 @@ var blossomCmd = &cli.Command{
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[files...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
servers := c.StringSlice("server")
|
||||
|
||||
if len(servers) == 0 {
|
||||
return fmt.Errorf("no server specified")
|
||||
}
|
||||
|
||||
if isPiped() {
|
||||
// get file from stdin
|
||||
if c.Args().Len() > 0 {
|
||||
@@ -89,34 +96,36 @@ var blossomCmd = &cli.Command{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read stdin: %w", err)
|
||||
}
|
||||
|
||||
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
} else {
|
||||
// get filenames from arguments
|
||||
hasError := false
|
||||
for _, fpath := range c.Args().Slice() {
|
||||
bd, err := client.UploadFilePath(ctx, fpath)
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
hasError = true
|
||||
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
} else {
|
||||
// get filenames from arguments
|
||||
for _, fpath := range c.Args().Slice() {
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadFilePath(ctx, fpath)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(3)
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -142,12 +151,24 @@ var blossomCmd = &cli.Command{
|
||||
outputs := c.StringSlice("output")
|
||||
|
||||
hasError := false
|
||||
for i, hash := range c.Args().Slice() {
|
||||
var hash [32]byte
|
||||
for i, hhash := range c.Args().Slice() {
|
||||
if len(hhash) != 64 {
|
||||
log("invalid blob hash '%s'\n", hhash)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
|
||||
log("invalid blob hash '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
if len(outputs)-1 >= i && outputs[i] != "--" {
|
||||
// save to this file
|
||||
err := client.DownloadToFile(ctx, hash, outputs[i])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
log("download failed for '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
@@ -158,7 +179,7 @@ var blossomCmd = &cli.Command{
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
stdout(data)
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +238,11 @@ if any of the files are not found the command will fail, otherwise it will succe
|
||||
|
||||
hasError := false
|
||||
for _, hash := range c.Args().Slice() {
|
||||
// a sha256 is 64 hex characters, anything after that is an extension
|
||||
if len(hash) > 64 {
|
||||
hash = hash[0:64]
|
||||
}
|
||||
|
||||
err := client.Check(ctx, hash)
|
||||
if err != nil {
|
||||
hasError = true
|
||||
@@ -234,47 +260,55 @@ if any of the files are not found the command will fail, otherwise it will succe
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mirror",
|
||||
Usage: "mirrors blobs from source server to target server",
|
||||
Description: `lists all blobs from the source server and mirrors them to the target server using BUD-04. requires --sec to sign the authorization event.`,
|
||||
Name: "mirror",
|
||||
Usage: "mirrors a from a server to another",
|
||||
Description: `examples:
|
||||
mirroring a single blob:
|
||||
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
|
||||
|
||||
mirroring all blobs from a certain pubkey from one server to the other:
|
||||
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
targetClient, err := getBlossomClient(ctx, c)
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create client for source server
|
||||
sourceServer := c.Args().First()
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceClient := blossom.NewClient(sourceServer, keyer)
|
||||
|
||||
// Get list of blobs from source server
|
||||
bds, err := sourceClient.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list blobs from source server: %w", err)
|
||||
}
|
||||
|
||||
// Mirror each blob to target server
|
||||
hasError := false
|
||||
for _, bd := range bds {
|
||||
mirrored, err := mirrorBlob(ctx, targetClient, bd.URL)
|
||||
var bd blossom.BlobDescriptor
|
||||
if input := c.Args().First(); input != "" {
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to mirror %s: %s\n", bd.SHA256, err)
|
||||
hasError = true
|
||||
return err
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
for input := range getJsonsOrBlank() {
|
||||
if input == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(mirrored)
|
||||
stdout(string(j))
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
|
||||
continue
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(string(out))
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(3)
|
||||
}
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -286,84 +320,9 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blossom.NewClient(c.String("server"), keyer), nil
|
||||
}
|
||||
|
||||
// mirrorBlob mirrors a blob from a URL to the mediaserver using BUD-04
|
||||
func mirrorBlob(ctx context.Context, client *blossom.Client, url string) (*blossom.BlobDescriptor, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download blob from URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to download blob: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read blob content: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
signer := client.GetSigner()
|
||||
pubkey, _ := signer.GetPublicKey(ctx)
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: 24242,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"t", "upload"},
|
||||
{"x", hashHex},
|
||||
{"expiration", fmt.Sprintf("%d", nostr.Now()+60)},
|
||||
},
|
||||
Content: "blossom stuff",
|
||||
PubKey: pubkey,
|
||||
}
|
||||
|
||||
if err := signer.SignEvent(ctx, &evt); err != nil {
|
||||
return nil, fmt.Errorf("failed to sign authorization event: %w", err)
|
||||
}
|
||||
|
||||
evtj, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal authorization event: %w", err)
|
||||
}
|
||||
auth := base64.StdEncoding.EncodeToString(evtj)
|
||||
|
||||
mediaserver := client.GetMediaServer()
|
||||
mirrorURL := mediaserver + "mirror"
|
||||
|
||||
requestBody := map[string]string{"url": url}
|
||||
requestJSON, _ := json.Marshal(requestBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", mirrorURL, bytes.NewReader(requestJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create mirror request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Nostr "+auth)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
mirrorResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send mirror request: %w", err)
|
||||
}
|
||||
defer mirrorResp.Body.Close()
|
||||
|
||||
if mirrorResp.StatusCode < 200 || mirrorResp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(mirrorResp.Body)
|
||||
return nil, fmt.Errorf("mirror request failed with HTTP %d: %s", mirrorResp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var bd blossom.BlobDescriptor
|
||||
if err := json.NewDecoder(mirrorResp.Body).Decode(&bd); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode blob descriptor: %w", err)
|
||||
}
|
||||
|
||||
return &bd, nil
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blossom.NewClient(servers[0], keyer), nil
|
||||
}
|
||||
|
||||
377
bunker.go
377
bunker.go
@@ -4,7 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,15 +45,6 @@ var bunker = &cli.Command{
|
||||
OnlyOnce: true,
|
||||
Category: PERSISTENCE,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as hex or nsec",
|
||||
DefaultText: "the key '1'",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "prompt-sec",
|
||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "authorized-secrets",
|
||||
Aliases: []string{"s"},
|
||||
@@ -73,14 +67,8 @@ var bunker = &cli.Command{
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// read config from file
|
||||
config := struct {
|
||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
|
||||
Secret plainOrEncryptedKey `json:"sec"`
|
||||
Relays []string `json:"relays"`
|
||||
}{
|
||||
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
|
||||
}
|
||||
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
config := BunkerConfig{}
|
||||
baseRelaysUrls := nostr.AppendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
for i, url := range baseRelaysUrls {
|
||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
@@ -122,16 +110,16 @@ var bunker = &cli.Command{
|
||||
|
||||
persist = func() {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +130,15 @@ var bunker = &cli.Command{
|
||||
if err := json.Unmarshal(b, &config); err != nil {
|
||||
return err
|
||||
}
|
||||
// convert from deprecated field
|
||||
if len(config.AuthorizedKeys) > 0 {
|
||||
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
|
||||
for i := range config.AuthorizedKeys {
|
||||
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
|
||||
}
|
||||
config.AuthorizedKeys = nil
|
||||
persist()
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
@@ -149,8 +146,12 @@ var bunker = &cli.Command{
|
||||
for i, url := range config.Relays {
|
||||
config.Relays[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
|
||||
config.Relays = nostr.AppendUnique(config.Relays, baseRelaysUrls...)
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||
// we don't have any secret key stored, so just use whatever was given via flags
|
||||
@@ -167,14 +168,16 @@ var bunker = &cli.Command{
|
||||
} else {
|
||||
config.Secret = baseSecret
|
||||
config.Relays = baseRelaysUrls
|
||||
config.AuthorizedKeys = baseAuthorizedKeys
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||
sec := os.Getenv("NOSTR_SECRET_KEY")
|
||||
if sec == "" {
|
||||
sec = defaultKey
|
||||
sec = defaultKey().Hex()
|
||||
}
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
@@ -205,17 +208,24 @@ var bunker = &cli.Command{
|
||||
|
||||
// try to connect to the relays here
|
||||
qs := url.Values{}
|
||||
relayURLs := make([]string, 0, len(config.Relays))
|
||||
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
|
||||
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||
copy(allRelays, config.Relays)
|
||||
for _, c := range config.Clients {
|
||||
for _, url := range c.CustomRelays {
|
||||
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
|
||||
allRelays = append(allRelays, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
relays := connectToAllRelays(ctx, c, allRelays)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
for _, relay := range relays {
|
||||
relayURLs = append(relayURLs, relay.URL)
|
||||
qs.Add("relay", relay.URL)
|
||||
for _, relay := range config.Relays {
|
||||
qs.Add("relay", relay)
|
||||
}
|
||||
if len(relayURLs) == 0 {
|
||||
if len(relays) == 0 {
|
||||
return fmt.Errorf("not connected to any relays: please specify at least one")
|
||||
}
|
||||
|
||||
@@ -226,20 +236,41 @@ var bunker = &cli.Command{
|
||||
// it will be stored
|
||||
newSecret := randString(12)
|
||||
|
||||
// guards config.Clients, newSecret and cancelPreviousBunkerInfoPrint, which are
|
||||
// accessed from the socket goroutine, the per-request handler goroutines and here
|
||||
var mu sync.Mutex
|
||||
|
||||
// static information
|
||||
pubkey := sec.Public()
|
||||
npub := nip19.EncodeNpub(pubkey)
|
||||
|
||||
// this function will be called every now and then
|
||||
printBunkerInfo := func() {
|
||||
qs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
iqs := make(url.Values)
|
||||
maps.Copy(iqs, qs)
|
||||
iqs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), iqs.Encode())
|
||||
|
||||
authorizedKeysStr := ""
|
||||
if len(config.AuthorizedKeys) != 0 {
|
||||
authorizedKeysStr = "\n authorized keys:"
|
||||
for _, pubkey := range config.AuthorizedKeys {
|
||||
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
|
||||
if len(config.Clients) != 0 {
|
||||
authorizedKeysStr = "\n authorized clients:"
|
||||
for _, c := range config.Clients {
|
||||
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
|
||||
name := ""
|
||||
if c.Name != "" {
|
||||
name = c.Name
|
||||
if c.URL != "" {
|
||||
name += " " + colors.underline(c.URL)
|
||||
}
|
||||
} else if c.URL != "" {
|
||||
name = colors.underline(c.URL)
|
||||
}
|
||||
if name != "" {
|
||||
authorizedKeysStr += " (" + name + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +280,8 @@ var bunker = &cli.Command{
|
||||
}
|
||||
|
||||
preauthorizedFlags := ""
|
||||
for _, k := range config.AuthorizedKeys {
|
||||
preauthorizedFlags += " -k " + k.Hex()
|
||||
for _, c := range config.Clients {
|
||||
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||
}
|
||||
for _, s := range authorizedSecrets {
|
||||
preauthorizedFlags += " -s " + s
|
||||
@@ -261,8 +292,8 @@ var bunker = &cli.Command{
|
||||
secretKeyFlag = "--sec " + sec
|
||||
}
|
||||
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
|
||||
for i, url := range relayURLs {
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(config.Relays))
|
||||
for i, url := range config.Relays {
|
||||
if strings.HasPrefix(url, "wss://") {
|
||||
relayURLsPossiblyWithoutSchema[i] = url[6:]
|
||||
} else {
|
||||
@@ -279,7 +310,7 @@ var bunker = &cli.Command{
|
||||
)
|
||||
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
||||
colors.bold(relayURLs),
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
@@ -290,7 +321,7 @@ var bunker = &cli.Command{
|
||||
} else {
|
||||
// otherwise just print the data
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
|
||||
colors.bold(relayURLs),
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
@@ -309,33 +340,42 @@ var bunker = &cli.Command{
|
||||
printBunkerInfo()
|
||||
|
||||
// subscribe to relays
|
||||
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
|
||||
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{
|
||||
Label: "nak-bunker",
|
||||
})
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||
|
||||
signer := nip46.NewStaticKeySigner(sec)
|
||||
handlerWg := sync.WaitGroup{}
|
||||
printLock := sync.Mutex{}
|
||||
signer.DefaultRelays = config.Relays
|
||||
|
||||
// just a gimmick
|
||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||
_, cancel := context.WithCancel(ctx)
|
||||
cancelPreviousBunkerInfoPrint = cancel
|
||||
|
||||
// asking user for authorization
|
||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(authorizedSecrets, secret) {
|
||||
// add client to authorized list for subsequent requests
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if secret == newSecret {
|
||||
// store this key
|
||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
// discard this and generate a new secret
|
||||
newSecret = randString(12)
|
||||
// print bunker info again after this
|
||||
@@ -354,44 +394,55 @@ var bunker = &cli.Command{
|
||||
return false
|
||||
}
|
||||
|
||||
for ie := range events {
|
||||
handleBunkerRequest := func(ie nostr.RelayEvent) {
|
||||
mu.Lock()
|
||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||
mu.Unlock()
|
||||
|
||||
// handle the NIP-46 request event
|
||||
from := ie.Event.PubKey
|
||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||
if err != nil {
|
||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
||||
continue
|
||||
if errors.Is(err, nip46.AlreadyHandled) {
|
||||
return
|
||||
}
|
||||
|
||||
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
|
||||
handlerWg.Add(len(relayURLs))
|
||||
for _, relayURL := range relayURLs {
|
||||
go func(relayURL string) {
|
||||
defer handlerWg.Done()
|
||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
||||
err := relay.Publish(ctx, eventResponse)
|
||||
printLock.Lock()
|
||||
if err == nil {
|
||||
log("* sent response through %s\n", relay.URL)
|
||||
} else {
|
||||
log("* failed to send response: %s\n", err)
|
||||
}
|
||||
printLock.Unlock()
|
||||
}
|
||||
}(relayURL)
|
||||
// use custom relays if they are defined for this client
|
||||
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||
relays := config.Relays
|
||||
mu.Lock()
|
||||
for _, c := range config.Clients {
|
||||
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||
relays = c.CustomRelays
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent response through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
handlerWg.Wait()
|
||||
|
||||
// just after handling one request we trigger this
|
||||
go func() {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
mu.Lock()
|
||||
cancelPreviousBunkerInfoPrint = cancel
|
||||
mu.Unlock()
|
||||
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
|
||||
// but we will only do if the bunker is inactive for more than 5 minutes
|
||||
select {
|
||||
@@ -403,6 +454,72 @@ var bunker = &cli.Command{
|
||||
}()
|
||||
}
|
||||
|
||||
// unix socket nostrconnect:// handling
|
||||
go func() {
|
||||
for uri := range onSocketConnect(ctx, c) {
|
||||
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
|
||||
|
||||
relays := uri.Query()["relay"]
|
||||
|
||||
// pre-authorize this client since the user has explicitly added it
|
||||
mu.Lock()
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
||||
return c.PubKey == clientPublicKey
|
||||
}) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{
|
||||
PubKey: clientPublicKey,
|
||||
Name: uri.Query().Get("name"),
|
||||
URL: uri.Query().Get("url"),
|
||||
Icon: uri.Query().Get("icon"),
|
||||
CustomRelays: relays,
|
||||
})
|
||||
}
|
||||
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
||||
if err != nil {
|
||||
log("* failed to handle: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
|
||||
// handle directly instead of forwarding into the main events
|
||||
// channel, which is owned (and eventually closed) by the pool
|
||||
go handleBunkerRequest(event)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(time.Millisecond * 25)
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for ie := range events {
|
||||
go handleBunkerRequest(ie)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
@@ -410,24 +527,44 @@ var bunker = &cli.Command{
|
||||
Name: "connect",
|
||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||
ArgsUsage: "<nostrconnect-uri>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "profile",
|
||||
Usage: "profile name of the bunker to connect to",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||
}
|
||||
|
||||
uri, err := url.Parse(c.Args().First())
|
||||
if err != nil || uri.Scheme != "nostrconnect" {
|
||||
return fmt.Errorf("invalid uri")
|
||||
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||
}
|
||||
|
||||
// TODO
|
||||
|
||||
return fmt.Errorf("this is not implemented yet")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type BunkerConfig struct {
|
||||
Clients []BunkerConfigClient `json:"clients"`
|
||||
Secret plainOrEncryptedKey `json:"sec"`
|
||||
Relays []string `json:"relays"`
|
||||
|
||||
// deprecated
|
||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
|
||||
}
|
||||
|
||||
type BunkerConfigClient struct {
|
||||
PubKey nostr.PubKey `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
CustomRelays []string `json:"custom_relays,omitempty"`
|
||||
}
|
||||
|
||||
type plainOrEncryptedKey struct {
|
||||
Plain *nostr.SecretKey
|
||||
Encrypted *string
|
||||
@@ -495,3 +632,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func getSocketPath(c *cli.Command) string {
|
||||
profile := "default"
|
||||
if c.IsSet("profile") {
|
||||
profile = c.String("profile")
|
||||
}
|
||||
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
||||
}
|
||||
|
||||
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
||||
res := make(chan *url.URL)
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
// ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
log(color.RedString("failed to create socket directory: %s\n", err))
|
||||
return res
|
||||
}
|
||||
|
||||
// delete existing socket file if it exists
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
log(color.RedString("failed to remove existing socket file: %s\n", err))
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
|
||||
return res
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath) // cleanup socket file on exit
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
uri, err := url.Parse(string(buf[:n]))
|
||||
if err == nil && uri.Scheme == "nostrconnect" {
|
||||
res <- uri
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func sendToSocket(c *cli.Command, value string) error {
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send uri to bunker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
14
cli_test.go
14
cli_test.go
@@ -229,3 +229,17 @@ func TestNaturalTimestamps(t *testing.T) {
|
||||
require.Equal(t, nostr.Timestamp(1526711839), evt.CreatedAt)
|
||||
require.Equal(t, "nn", evt.Content)
|
||||
}
|
||||
|
||||
func TestEventJQ(t *testing.T) {
|
||||
// default: string results come out JSON-quoted, like plain jq
|
||||
quoted := call(t, "nak event --ts 1699485669 -c hello --jq .content")
|
||||
require.Equal(t, `"hello"`, quoted)
|
||||
|
||||
// --jq-raw: string results come out unquoted, like `jq -r`
|
||||
raw := call(t, "nak event --ts 1699485669 -c hello --jq .content --jq-raw")
|
||||
require.Equal(t, "hello", raw)
|
||||
|
||||
// --jq-raw on a non-string result is still JSON-encoded, like `jq -r`
|
||||
num := call(t, "nak event --ts 1699485669 -k 7 --jq .kind --jq-raw")
|
||||
require.Equal(t, "7", num)
|
||||
}
|
||||
|
||||
203
count.go
203
count.go
@@ -9,68 +9,26 @@ import (
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip45"
|
||||
"fiatjaf.com/nostr/nip45/hyperloglog"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var count = &cli.Command{
|
||||
Name: "count",
|
||||
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
|
||||
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
|
||||
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&PubKeySliceFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "only accept events with these kind numbers",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "e",
|
||||
Usage: "shortcut for --tag e=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "p",
|
||||
Usage: "shortcut for --tag p=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "only accept events newer than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "until",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "only accept events older than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "only accept up to this number of events",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
},
|
||||
ArgsUsage: "[relay...]",
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags}),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
biggerUrlSize := 0
|
||||
relayUrls := c.Args().Slice()
|
||||
if len(relayUrls) > 0 {
|
||||
relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{})
|
||||
relays := connectToAllRelays(
|
||||
ctx,
|
||||
c,
|
||||
relayUrls,
|
||||
)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
@@ -84,95 +42,76 @@ var count = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
filter := nostr.Filter{}
|
||||
|
||||
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
|
||||
filter.Authors = authors
|
||||
}
|
||||
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
|
||||
kinds := make([]nostr.Kind, len(kinds64))
|
||||
for i, v := range kinds64 {
|
||||
kinds[i] = nostr.Kind(v)
|
||||
}
|
||||
filter.Kinds = kinds
|
||||
}
|
||||
|
||||
tags := make([][]string, 0, 5)
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
|
||||
} else {
|
||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||
}
|
||||
}
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = append(tags, []string{"e", decodeTagValue(etag)})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", decodeTagValue(ptag)})
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
filter.Tags = make(nostr.TagMap)
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsSet("since") {
|
||||
filter.Since = getNaturalDate(c, "since")
|
||||
}
|
||||
if c.IsSet("until") {
|
||||
filter.Until = getNaturalDate(c, "until")
|
||||
}
|
||||
|
||||
if limit := c.Int("limit"); limit != 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
relay, _ := sys.Pool.EnsureRelay(relayUrl)
|
||||
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-count",
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
|
||||
// go line by line from stdin or run once with input from flags
|
||||
for stdinFilter := range getJsonsOrBlank() {
|
||||
filter := nostr.Filter{}
|
||||
if stdinFilter != "" {
|
||||
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " 📋"
|
||||
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
nm := nostr.NormalizeURL(relayUrl)
|
||||
relay, ok := sys.Pool.Relays.Load(nm)
|
||||
if !ok || relay == nil || !relay.IsConnected() {
|
||||
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
var err error
|
||||
relay, err = nostr.RelayConnect(ct, relayUrl, sys.Pool.RelayOptions)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
sys.Pool.Relays.Store(nm, relay)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-count",
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " (hll)"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
13
curl.go
13
curl.go
@@ -19,8 +19,10 @@ var curl = &cli.Command{
|
||||
Name: "curl",
|
||||
Usage: "calls curl but with a nip98 header",
|
||||
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
|
||||
Flags: defaultKeyFlags,
|
||||
DisableSliceFlagSeparator: true,
|
||||
// this command is always run standalone (see realCurl()), never under the root
|
||||
// command, so it needs its own copy of the key flags
|
||||
Flags: defaultKeyFlags,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
@@ -120,13 +122,16 @@ func realCurl() error {
|
||||
keyFlags = append(keyFlags, arg)
|
||||
if arg != "--prompt-sec" {
|
||||
i++
|
||||
val := os.Args[i+2]
|
||||
keyFlags = append(keyFlags, val)
|
||||
if i+2 >= len(os.Args) {
|
||||
break
|
||||
}
|
||||
keyFlags = append(keyFlags, os.Args[i+2])
|
||||
}
|
||||
} else {
|
||||
curlFlags = append(curlFlags, arg)
|
||||
}
|
||||
}
|
||||
|
||||
return curl.Run(context.Background(), keyFlags)
|
||||
// Run treats the first element as the program name, so prepend one
|
||||
return curl.Run(context.Background(), append([]string{"nak curl"}, keyFlags...))
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ var decode = &cli.Command{
|
||||
continue
|
||||
}
|
||||
|
||||
ctx = lineProcessingError(ctx, "couldn't decode input '%s'", input)
|
||||
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
14
dekey.go
14
dekey.go
@@ -20,7 +20,7 @@ var dekey = &cli.Command{
|
||||
Usage: "handles NIP-4E decoupled encryption keys",
|
||||
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Usage: "name of this device that will be published and displayed on other clients",
|
||||
@@ -87,7 +87,7 @@ var dekey = &cli.Command{
|
||||
// get relays for the user
|
||||
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
|
||||
relays := sys.FetchWriteRelays(ctx, userPub)
|
||||
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
|
||||
relayList := connectToAllRelays(ctx, c, relays)
|
||||
if len(relayList) == 0 {
|
||||
return fmt.Errorf("no relays to use")
|
||||
}
|
||||
@@ -164,7 +164,7 @@ var dekey = &cli.Command{
|
||||
return fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||
return fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
|
||||
}
|
||||
} else {
|
||||
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
|
||||
@@ -236,12 +236,14 @@ var dekey = &cli.Command{
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eSec, err = nostr.SecretKeyFromHex(eSecHex)
|
||||
candidate, err := nostr.SecretKeyFromHex(eSecHex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// check if it matches mainPub
|
||||
if eSec.Public() == ePub {
|
||||
// check if it matches mainPub -- only keep it if it does, otherwise a
|
||||
// stale key received from another device would end up being redistributed
|
||||
if candidate.Public() == ePub {
|
||||
eSec = candidate
|
||||
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
|
||||
// store it
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
|
||||
212
encode.go
212
encode.go
@@ -2,13 +2,67 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func parseProfilePointerInput(target string) (nostr.ProfilePointer, bool) {
|
||||
var profilePtr nostr.ProfilePointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &profilePtr); err != nil || profilePtr.PublicKey == nostr.ZeroPK {
|
||||
return nostr.ProfilePointer{}, false
|
||||
}
|
||||
return profilePtr, true
|
||||
}
|
||||
|
||||
func parseEventPointerInput(target string) (nostr.EventPointer, bool) {
|
||||
var eventPtr nostr.EventPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &eventPtr); err != nil || eventPtr.ID == nostr.ZeroID {
|
||||
return nostr.EventPointer{}, false
|
||||
}
|
||||
return eventPtr, true
|
||||
}
|
||||
|
||||
func parseEntityPointerInput(target string) (nostr.EntityPointer, bool) {
|
||||
var entityPtr nostr.EntityPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &entityPtr); err != nil || entityPtr.PublicKey == nostr.ZeroPK || entityPtr.Kind == 0 {
|
||||
return nostr.EntityPointer{}, false
|
||||
}
|
||||
return entityPtr, true
|
||||
}
|
||||
|
||||
func getEncodeSubcommandInput(args cli.Args, allowBlank bool) iter.Seq[string] {
|
||||
if args.Len() > 0 {
|
||||
return func(yield func(string) bool) {
|
||||
for _, arg := range args.Slice() {
|
||||
if !yield(arg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(yield func(string) bool) {
|
||||
for jsonStr := range getJsonsOrBlank() {
|
||||
if jsonStr == "{}" {
|
||||
if allowBlank {
|
||||
yield("")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(jsonStr) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var encode = &cli.Command{
|
||||
Name: "encode",
|
||||
Usage: "encodes notes and other stuff to nip19 entities",
|
||||
@@ -21,14 +75,19 @@ var encode = &cli.Command{
|
||||
nak encode nsec <privkey-hex>
|
||||
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
|
||||
echo '{
|
||||
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19"
|
||||
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19",
|
||||
"relays":["wss://nada.zero"],
|
||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||
} | nak encode`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 0 {
|
||||
return nil
|
||||
switch c.Args().First() {
|
||||
case "naddr", "nevent", "npub", "nprofile", "nsec":
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
@@ -45,21 +104,18 @@ var encode = &cli.Command{
|
||||
hasStdin = true
|
||||
}
|
||||
|
||||
var eventPtr nostr.EventPointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &eventPtr); err == nil && eventPtr.ID != nostr.ZeroID {
|
||||
stdout(nip19.EncodeNevent(eventPtr.ID, appendUnique(relays, eventPtr.Relays...), eventPtr.Author))
|
||||
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
|
||||
continue
|
||||
}
|
||||
|
||||
var profilePtr nostr.ProfilePointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &profilePtr); err == nil && profilePtr.PublicKey != nostr.ZeroPK {
|
||||
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, appendUnique(relays, profilePtr.Relays...)))
|
||||
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
var entityPtr nostr.EntityPointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &entityPtr); err == nil && entityPtr.PublicKey != nostr.ZeroPK {
|
||||
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, appendUnique(relays, entityPtr.Relays...)))
|
||||
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -82,7 +138,7 @@ var encode = &cli.Command{
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -101,7 +157,7 @@ var encode = &cli.Command{
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
sk, err := nostr.SecretKeyFromHex(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid private key '%s': %w", target, err)
|
||||
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -129,18 +185,25 @@ var encode = &cli.Command{
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
relays := c.StringSlice("relay")
|
||||
pk := nostr.ZeroPK
|
||||
|
||||
if profilePtr, ok := parseProfilePointerInput(target); ok {
|
||||
pk = profilePtr.PublicKey
|
||||
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
|
||||
} else {
|
||||
var err error
|
||||
pk, err = nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,19 +240,29 @@ var encode = &cli.Command{
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
id, err := parseEventID(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
id := nostr.ZeroID
|
||||
author := getPubKey(c, "author")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if eventPtr, ok := parseEventPointerInput(target); ok {
|
||||
id = eventPtr.ID
|
||||
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
|
||||
if author == nostr.ZeroPK {
|
||||
author = eventPtr.Author
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
id, err = parseEventID(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,22 +282,19 @@ var encode = &cli.Command{
|
||||
Usage: "generate codes for addressable events",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
Required: true,
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
Required: true,
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
Required: true,
|
||||
&KindFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
@@ -236,30 +306,68 @@ var encode = &cli.Command{
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for d := range getStdinLinesOrBlank() {
|
||||
for target := range getEncodeSubcommandInput(c.Args(), true) {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
kind := getKind(c, "kind")
|
||||
d := c.String("identifier")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
kind := c.Int("kind")
|
||||
if kind < 30000 || kind >= 40000 {
|
||||
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
|
||||
if entityPtr, ok := parseEntityPointerInput(target); ok {
|
||||
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
|
||||
if pubkey == nostr.ZeroPK {
|
||||
pubkey = entityPtr.PublicKey
|
||||
}
|
||||
if kind == 0 {
|
||||
kind = entityPtr.Kind
|
||||
}
|
||||
if !c.IsSet("identifier") {
|
||||
d = entityPtr.Identifier
|
||||
}
|
||||
} else if target != "" {
|
||||
d = target
|
||||
}
|
||||
|
||||
if d == "" {
|
||||
d = c.String("identifier")
|
||||
if pubkey == nostr.ZeroPK {
|
||||
ctx = lineProcessingError(ctx, "pubkey must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if kind == 0 {
|
||||
ctx = lineProcessingError(ctx, "kind must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if kind.IsAddressable() {
|
||||
if d == "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
|
||||
continue
|
||||
}
|
||||
} else if kind.IsReplaceable() {
|
||||
if d != "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
ctx = lineProcessingError(ctx, "can only encode addressable events")
|
||||
continue
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ var encrypt = &cli.Command{
|
||||
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
|
||||
ArgsUsage: "[plaintext string]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
@@ -24,7 +23,7 @@ var encrypt = &cli.Command{
|
||||
Name: "nip04",
|
||||
Usage: "use nip04 encryption instead of nip44",
|
||||
},
|
||||
),
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
target := getPubKey(c, "recipient-pubkey")
|
||||
|
||||
@@ -75,8 +74,7 @@ var decrypt = &cli.Command{
|
||||
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
|
||||
ArgsUsage: "[ciphertext base64]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "sender-pubkey",
|
||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
||||
@@ -86,7 +84,7 @@ var decrypt = &cli.Command{
|
||||
Name: "nip04",
|
||||
Usage: "use nip04 encryption instead of nip44",
|
||||
},
|
||||
),
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
source := getPubKey(c, "sender-pubkey")
|
||||
|
||||
@@ -123,7 +121,7 @@ var decrypt = &cli.Command{
|
||||
|
||||
res, err := kr.Decrypt(ctx, ciphertext, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt: %w", err)
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
stdout(res)
|
||||
}
|
||||
|
||||
225
event.go
225
event.go
@@ -13,6 +13,7 @@ import (
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip13"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
|
||||
CATEGORY_SIGNER = "SIGNER OPTIONS"
|
||||
CATEGORY_EXTRAS = "EXTRAS"
|
||||
CATEGORY_AUTH = "AUTH"
|
||||
)
|
||||
|
||||
var eventCmd = &cli.Command{
|
||||
@@ -39,7 +41,7 @@ example:
|
||||
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
|
||||
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
// ~ these args are only for the convoluted musig2 signing process
|
||||
// they will be generally copy-shared-pasted across some manual coordination method between participants
|
||||
&cli.UintFlag{
|
||||
@@ -66,6 +68,18 @@ example:
|
||||
Hidden: true,
|
||||
},
|
||||
// ~~~
|
||||
&cli.BoolFlag{
|
||||
Name: "force-sign",
|
||||
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-sign",
|
||||
Usage: "print the event without signing it, using the specified pubkey",
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "pow",
|
||||
Usage: "nip13 difficulty to target when doing hash work on the event id",
|
||||
@@ -76,9 +90,14 @@ example:
|
||||
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@@ -86,14 +105,26 @@ example:
|
||||
Usage: "print the nevent code (to stderr) after the event is published",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&KindFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "event kind",
|
||||
Usage: "event kind number or name",
|
||||
DefaultText: "1",
|
||||
Value: 0,
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "set the event pubkey or add an 'a' tag if it's an address",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "content",
|
||||
Aliases: []string{"c"},
|
||||
@@ -123,6 +154,11 @@ example:
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "created-at",
|
||||
Aliases: []string{"time", "ts"},
|
||||
@@ -136,31 +172,31 @@ example:
|
||||
Usage: "ask before publishing the event",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// try to connect to the relays here
|
||||
var relays []*nostr.Relay
|
||||
|
||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
argRelayUrls := c.Args().Slice()
|
||||
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// then process input and generate events:
|
||||
|
||||
// will reuse this
|
||||
@@ -169,6 +205,11 @@ example:
|
||||
// this is called when we have a valid json from stdin
|
||||
handleEvent := func(stdinEvent string) error {
|
||||
evt.Content = ""
|
||||
evt.CreatedAt = 0
|
||||
evt.Tags = evt.Tags[:0]
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.PubKey = nostr.ZeroPK
|
||||
evt.Sig = [64]byte{}
|
||||
|
||||
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
|
||||
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
|
||||
@@ -178,8 +219,8 @@ example:
|
||||
return fmt.Errorf("invalid event received from stdin: %s", err)
|
||||
}
|
||||
|
||||
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
|
||||
evt.Kind = nostr.Kind(kind)
|
||||
if c.IsSet("kind") {
|
||||
evt.Kind = getKind(c, "kind")
|
||||
mustRehashAndResign = true
|
||||
} else if !kindWasSupplied {
|
||||
evt.Kind = 1
|
||||
@@ -212,8 +253,12 @@ example:
|
||||
if found {
|
||||
// tags may also contain extra elements separated with a ";"
|
||||
tagValues := strings.Split(tagValue, ";")
|
||||
val := tagValues[0]
|
||||
if len(tagName) == 1 {
|
||||
val = decodeTagValue(val, rune(tagName[0]))
|
||||
}
|
||||
if len(tagValues) >= 1 {
|
||||
tagValues[0] = decodeTagValue(tagValues[0])
|
||||
tagValues[0] = val
|
||||
}
|
||||
tag = append(tag, tagValues...)
|
||||
}
|
||||
@@ -221,21 +266,45 @@ example:
|
||||
}
|
||||
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
decodedEtag := decodeTagValue(etag)
|
||||
decodedEtag := decodeTagValue(etag, 'e')
|
||||
if tags.FindWithValue("e", decodedEtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"e", decodedEtag})
|
||||
}
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
decodedPtag := decodeTagValue(ptag)
|
||||
decodedPtag := decodeTagValue(ptag, 'p')
|
||||
if tags.FindWithValue("p", decodedPtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"p", decodedPtag})
|
||||
}
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
decodedDtag := decodeTagValue(dtag)
|
||||
if tags.FindWithValue("d", decodedDtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"d", decodedDtag})
|
||||
if tags.FindWithValue("d", dtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"d", dtag})
|
||||
}
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
if tags.FindWithValue("h", htag) == nil {
|
||||
tags = append(tags, nostr.Tag{"h", htag})
|
||||
}
|
||||
}
|
||||
|
||||
var authorPubKey nostr.PubKey
|
||||
for _, a := range getPubKeyOrAddressSlice(c, "author") {
|
||||
// is it an address?
|
||||
if a.Addr != nil {
|
||||
aTag := a.Addr.AsTagReference()
|
||||
if tags.FindWithValue("a", aTag) == nil {
|
||||
tags = append(tags, nostr.Tag{"a", aTag})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if a.PubKey != nostr.ZeroPK {
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
return fmt.Errorf("multiple author pubkeys provided")
|
||||
}
|
||||
authorPubKey = a.PubKey
|
||||
}
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
@@ -244,6 +313,9 @@ example:
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
evt.PubKey = authorPubKey
|
||||
}
|
||||
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
@@ -253,7 +325,7 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
|
||||
if c.IsSet("musig") || c.Bool("force-sign") {
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
@@ -268,7 +340,7 @@ example:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||
}
|
||||
|
||||
@@ -279,7 +351,13 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if evt.Sig == [64]byte{} || mustRehashAndResign {
|
||||
if c.Bool("no-sign") {
|
||||
if evt.PubKey == nostr.ZeroPK {
|
||||
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
|
||||
}
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.Sig = [64]byte{}
|
||||
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
|
||||
if numSigners := c.Uint("musig"); numSigners > 1 {
|
||||
// must do musig
|
||||
pubkeys := c.StringSlice("musig-pubkey")
|
||||
@@ -304,16 +382,62 @@ example:
|
||||
}
|
||||
}
|
||||
|
||||
// print event as json
|
||||
var result string
|
||||
if c.Bool("envelope") {
|
||||
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
||||
result = string(j)
|
||||
} else {
|
||||
j, _ := easyjson.Marshal(&evt)
|
||||
result = string(j)
|
||||
var relays []*nostr.Relay
|
||||
// start from the given relays on every event so --outbox additions
|
||||
// for one event don't leak into the next
|
||||
relayUrls := slices.Clone(argRelayUrls)
|
||||
if len(relayUrls) > 0 || c.Bool("outbox") {
|
||||
if c.Bool("outbox") {
|
||||
if evt.PubKey != nostr.ZeroPK {
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchWriteRelays(ctx, evt.PubKey)...)
|
||||
}
|
||||
|
||||
seenPubkeys := make(map[nostr.PubKey]struct{}, len(evt.Tags))
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") {
|
||||
continue
|
||||
}
|
||||
pk, err := nostr.PubKeyFromHex(tag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPubkeys[pk]; ok {
|
||||
continue
|
||||
}
|
||||
seenPubkeys[pk] = struct{}{}
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchInboxRelays(ctx, pk, 15)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, c, relayUrls)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// print event as json
|
||||
if jq == nil {
|
||||
var result string
|
||||
if c.Bool("envelope") {
|
||||
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
|
||||
result = string(j)
|
||||
} else {
|
||||
j, _ := easyjson.Marshal(&evt)
|
||||
result = string(j)
|
||||
}
|
||||
stdout(result)
|
||||
} else {
|
||||
out, matches, err := jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if matches {
|
||||
stdout(out)
|
||||
}
|
||||
}
|
||||
stdout(result)
|
||||
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
}
|
||||
@@ -340,7 +464,8 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
|
||||
if c.Bool("confirm") {
|
||||
relaysStr := make([]string, len(relays))
|
||||
for i, r := range relays {
|
||||
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
|
||||
_, after, _ := strings.Cut(r.URL, "://")
|
||||
relaysStr[i] = strings.ToLower(after)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
|
||||
@@ -418,9 +543,19 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
|
||||
defer cancel()
|
||||
|
||||
if !relay.IsConnected() {
|
||||
if new_, err := sys.Pool.EnsureRelay(relay.URL); err == nil {
|
||||
relays[i] = new_
|
||||
relay = new_
|
||||
nm := nostr.NormalizeURL(relay.URL)
|
||||
if r, ok := sys.Pool.Relays.Load(nm); ok && r != nil && r.IsConnected() {
|
||||
relays[i] = r
|
||||
relay = r
|
||||
} else {
|
||||
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
new_, err := nostr.RelayConnect(ct, relay.URL, sys.Pool.RelayOptions)
|
||||
cancel()
|
||||
if err == nil {
|
||||
sys.Pool.Relays.Store(nm, new_)
|
||||
relays[i] = new_
|
||||
relay = new_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
fetch.go
40
fetch.go
@@ -18,15 +18,28 @@ var fetch = &cli.Command{
|
||||
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
|
||||
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(reqFilterFlags,
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[nip05_or_nip19_code]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for code := range getStdinLinesOrArguments(c.Args()) {
|
||||
filter := nostr.Filter{}
|
||||
var authorHint nostr.PubKey
|
||||
@@ -61,7 +74,7 @@ var fetch = &cli.Command{
|
||||
}
|
||||
relays = append(relays, v.Relays...)
|
||||
case "note":
|
||||
filter.IDs = append(filter.IDs, value.([32]byte))
|
||||
filter.IDs = append(filter.IDs, value.(nostr.EventPointer).ID)
|
||||
case "naddr":
|
||||
v := value.(nostr.EntityPointer)
|
||||
filter.Kinds = []nostr.Kind{v.Kind}
|
||||
@@ -88,7 +101,7 @@ var fetch = &cli.Command{
|
||||
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
|
||||
}
|
||||
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 6) {
|
||||
relays = append(relays, url)
|
||||
}
|
||||
}
|
||||
@@ -106,10 +119,29 @@ var fetch = &cli.Command{
|
||||
continue
|
||||
}
|
||||
|
||||
found := false
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-fetch",
|
||||
}) {
|
||||
stdout(ie.Event)
|
||||
found = true
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
v, matches, err := jq(ie.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
}
|
||||
|
||||
if !found {
|
||||
ctx = lineProcessingError(ctx, "no events found for %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
filter.go
36
filter.go
@@ -19,8 +19,17 @@ example:
|
||||
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
|
||||
`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: reqFilterFlags,
|
||||
ArgsUsage: "[event_json] [base_filter_json]",
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter matching events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[event_json] [base_filter_json]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
|
||||
@@ -54,6 +63,11 @@ example:
|
||||
return err
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if there is no stdin we'll still get an empty object here
|
||||
for evtj := range getJsonsOrBlank() {
|
||||
var evt nostr.Event
|
||||
@@ -72,6 +86,9 @@ example:
|
||||
if evt.Sig == [64]byte{} {
|
||||
evt.Sig = baseEvent.Sig
|
||||
}
|
||||
if evt.Kind == 0 {
|
||||
evt.Kind = baseEvent.Kind
|
||||
}
|
||||
if evt.Content == "" {
|
||||
evt.Content = baseEvent.Content
|
||||
}
|
||||
@@ -83,7 +100,20 @@ example:
|
||||
}
|
||||
|
||||
if baseFilter.Matches(evt) {
|
||||
stdout(evt)
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
} else {
|
||||
logverbose("event %s didn't match %s", evt, baseFilter)
|
||||
}
|
||||
|
||||
153
flags.go
153
flags.go
@@ -105,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, value)
|
||||
ts = date.Time
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts = date.Time
|
||||
}
|
||||
|
||||
if t.timestamp != nil {
|
||||
@@ -131,6 +131,45 @@ func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
SecretKeyFlag = cli.FlagBase[nostr.SecretKey, struct{}, secretkeyValue]
|
||||
)
|
||||
|
||||
type secretkeyValue struct {
|
||||
secretkey nostr.SecretKey
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.SecretKey, struct{}] = secretkeyValue{}
|
||||
|
||||
func (t secretkeyValue) Create(val nostr.SecretKey, p *nostr.SecretKey, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &secretkeyValue{
|
||||
secretkey: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t secretkeyValue) ToString(b nostr.SecretKey) string { return t.secretkey.String() }
|
||||
|
||||
func (t *secretkeyValue) Set(value string) error {
|
||||
secretkey, err := parseSecretKey(value)
|
||||
t.secretkey = secretkey
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *secretkeyValue) String() string { return fmt.Sprintf("%#v", t.secretkey) }
|
||||
func (t *secretkeyValue) Value() nostr.SecretKey { return t.secretkey }
|
||||
func (t *secretkeyValue) Get() any { return t.secretkey }
|
||||
|
||||
func getSecretKey(cmd *cli.Command, name string) nostr.SecretKey {
|
||||
return cmd.Value(name).(nostr.SecretKey)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
PubKeyFlag = cli.FlagBase[nostr.PubKey, struct{}, pubkeyValue]
|
||||
)
|
||||
@@ -183,6 +222,66 @@ func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
|
||||
//
|
||||
//
|
||||
|
||||
type PubKeyOrAddress struct {
|
||||
PubKey nostr.PubKey
|
||||
Addr *nostr.EntityPointer
|
||||
}
|
||||
|
||||
type (
|
||||
pubKeyOrAddressValue struct {
|
||||
value PubKeyOrAddress
|
||||
hasBeenSet bool
|
||||
}
|
||||
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
|
||||
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
|
||||
)
|
||||
|
||||
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
|
||||
|
||||
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &pubKeyOrAddressValue{
|
||||
value: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
|
||||
if b.Addr != nil {
|
||||
return b.Addr.AsTagReference()
|
||||
}
|
||||
return b.PubKey.String()
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) Set(value string) error {
|
||||
pubkey, err1 := parsePubKey(value)
|
||||
if err1 == nil {
|
||||
t.value = PubKeyOrAddress{PubKey: pubkey}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
addr, err2 := nostr.ParseAddrString(value)
|
||||
if err2 == nil {
|
||||
t.value = PubKeyOrAddress{Addr: &addr}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
|
||||
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
|
||||
func (t *pubKeyOrAddressValue) Get() any { return t.value }
|
||||
|
||||
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
|
||||
return cmd.Value(name).([]PubKeyOrAddress)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
|
||||
)
|
||||
@@ -229,3 +328,55 @@ type (
|
||||
func getIDSlice(cmd *cli.Command, name string) []nostr.ID {
|
||||
return cmd.Value(name).([]nostr.ID)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
KindFlag = cli.FlagBase[nostr.Kind, struct{}, kindValue]
|
||||
)
|
||||
|
||||
type kindValue struct {
|
||||
kind nostr.Kind
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[nostr.Kind, struct{}] = kindValue{}
|
||||
|
||||
func (t kindValue) Create(val nostr.Kind, p *nostr.Kind, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &kindValue{
|
||||
kind: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t kindValue) ToString(b nostr.Kind) string { return fmt.Sprintf("%d", b) }
|
||||
|
||||
func (t *kindValue) Set(value string) error {
|
||||
k, err := stringToKind(value)
|
||||
t.kind = k
|
||||
t.hasBeenSet = true
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *kindValue) String() string { return fmt.Sprintf("%#v", t.kind) }
|
||||
func (t *kindValue) Value() nostr.Kind { return t.kind }
|
||||
func (t *kindValue) Get() any { return t.kind }
|
||||
|
||||
func getKind(cmd *cli.Command, name string) nostr.Kind {
|
||||
return cmd.Value(name).(nostr.Kind)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
kindSlice = cli.SliceBase[nostr.Kind, struct{}, kindValue]
|
||||
KindSliceFlag = cli.FlagBase[[]nostr.Kind, struct{}, kindSlice]
|
||||
)
|
||||
|
||||
func getKindSlice(cmd *cli.Command, name string) []nostr.Kind {
|
||||
return cmd.Value(name).([]nostr.Kind)
|
||||
}
|
||||
|
||||
54
fs.go
54
fs.go
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !openbsd
|
||||
//go:build !windows && !openbsd && !cgofuse
|
||||
|
||||
package main
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fiatjaf/nak/nostrfs"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
@@ -22,7 +24,7 @@ var fsCmd = &cli.Command{
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
@@ -62,7 +64,7 @@ var fsCmd = &cli.Command{
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := NewFSRoot(
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
@@ -73,7 +75,7 @@ var fsCmd = &cli.Command{
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
FSOptions{
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
@@ -81,22 +83,21 @@ var fsCmd = &cli.Command{
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem
|
||||
mountArgs := []string{"-s", mountpoint}
|
||||
if isVerbose {
|
||||
mountArgs = append([]string{"-d"}, mountArgs...)
|
||||
timeout := time.Second * 120
|
||||
server, err := fs.Mount(mountpoint, root, &fs.Options{
|
||||
MountOptions: fuse.MountOptions{
|
||||
Debug: isVerbose,
|
||||
Name: "nak",
|
||||
FsName: "nak",
|
||||
RememberInodes: true,
|
||||
},
|
||||
AttrTimeout: &timeout,
|
||||
EntryTimeout: &timeout,
|
||||
Logger: nostr.DebugLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mount failed: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
host.Mount("", mountArgs)
|
||||
}()
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
// setup signal handling for clean unmount
|
||||
@@ -106,12 +107,17 @@ var fsCmd = &cli.Command{
|
||||
go func() {
|
||||
<-ch
|
||||
log("- unmounting... ")
|
||||
// cgofuse doesn't have explicit unmount, it unmounts on process exit
|
||||
log("ok\n")
|
||||
chErr <- nil
|
||||
err := server.Unmount()
|
||||
if err != nil {
|
||||
chErr <- fmt.Errorf("unmount failed: %w", err)
|
||||
} else {
|
||||
log("ok\n")
|
||||
chErr <- nil
|
||||
}
|
||||
}()
|
||||
|
||||
// wait for signals
|
||||
// serve the filesystem until unmounted
|
||||
server.Wait()
|
||||
return <-chErr
|
||||
},
|
||||
}
|
||||
|
||||
118
fs_cgo.go
Normal file
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
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
@@ -21,7 +22,7 @@ var fsCmd = &cli.Command{
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
@@ -61,7 +62,7 @@ var fsCmd = &cli.Command{
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := NewFSRoot(
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
@@ -72,7 +73,7 @@ var fsCmd = &cli.Command{
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
FSOptions{
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
|
||||
32
gift.go
32
gift.go
@@ -24,7 +24,6 @@ var gift = &cli.Command{
|
||||
|
||||
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: defaultKeyFlags,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "wrap",
|
||||
@@ -179,7 +178,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
}
|
||||
ciphers = append(ciphers, kr)
|
||||
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||
@@ -209,9 +208,12 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
for c, potentialCipher := range ciphers {
|
||||
switch c {
|
||||
case 0:
|
||||
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
case 1:
|
||||
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
if eSec.Public() == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
}
|
||||
|
||||
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||
@@ -227,7 +229,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
cipher = potentialCipher
|
||||
break
|
||||
}
|
||||
if seal.ID == nostr.ZeroID {
|
||||
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
|
||||
// if both ciphers failed above we'll reach here
|
||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||
}
|
||||
@@ -236,6 +238,10 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||
}
|
||||
|
||||
if !seal.VerifySignature() {
|
||||
return fmt.Errorf("seal signature is invalid")
|
||||
}
|
||||
|
||||
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
|
||||
@@ -247,11 +253,15 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
var rumor nostr.Event
|
||||
err = nil
|
||||
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
|
||||
if senderEncryptionPublicKey == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
|
||||
switch s {
|
||||
case 0:
|
||||
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
case 1:
|
||||
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
}
|
||||
|
||||
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
|
||||
@@ -267,10 +277,13 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
break
|
||||
}
|
||||
|
||||
if rumor.ID == nostr.ZeroID {
|
||||
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
|
||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
||||
}
|
||||
|
||||
rumor.PubKey = seal.PubKey
|
||||
rumor.ID = rumor.GetID()
|
||||
|
||||
// output the unwrapped event (rumor)
|
||||
stdout(rumor.String())
|
||||
}
|
||||
@@ -319,9 +332,8 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
|
||||
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
|
||||
}
|
||||
|
||||
return eSec, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
21
go.mod
21
go.mod
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||
@@ -12,12 +12,11 @@ require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/liamg/magic v0.0.1 // indirect
|
||||
github.com/liamg/magic v0.0.1
|
||||
github.com/mailru/easyjson v0.9.1
|
||||
github.com/mark3labs/mcp-go v0.8.3
|
||||
github.com/markusmobius/go-dateparser v1.2.3
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mattn/go-tty v0.0.7
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
@@ -28,6 +27,15 @@ require (
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.7
|
||||
github.com/denisbrodbeck/machineid v1.0.1
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
github.com/itchyny/gojq v0.12.19
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/mattn/go-tty/v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
@@ -49,6 +57,8 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chzyer/logex v1.1.10 // indirect
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
@@ -64,6 +74,7 @@ require (
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hablullah/go-hijri v1.0.2 // indirect
|
||||
github.com/hablullah/go-juliandays v1.0.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.8 // indirect
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
@@ -71,7 +82,7 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -97,7 +108,7 @@ require (
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
44
go.sum
44
go.sum
@@ -1,5 +1,7 @@
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
|
||||
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e h1:/vgoytiH4qQ28O/oX1hj656X69CnTy1pGj/17HFjH+A=
|
||||
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
||||
@@ -81,6 +83,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
@@ -96,6 +102,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
|
||||
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
@@ -142,11 +150,17 @@ github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnm
|
||||
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
||||
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
|
||||
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
|
||||
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
|
||||
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -165,8 +179,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
|
||||
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||
@@ -184,16 +202,18 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
|
||||
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-tty/v2 v2.0.0 h1:AgXDfbKcENaiQbjdQ+o2ZusbtiaOhK1ArFD75d5U6qk=
|
||||
github.com/mattn/go-tty/v2 v2.0.0/go.mod h1:azVwsnH46TUJRQPRp/IrUT+8nRkkvjvkESJJxAA4Y48=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -284,6 +304,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -292,11 +313,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -313,11 +336,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -325,11 +350,14 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
400
helpers.go
400
helpers.go
@@ -7,26 +7,29 @@ import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/fatih/color"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/mattn/go-tty"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -45,6 +48,8 @@ var (
|
||||
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
|
||||
)
|
||||
|
||||
var connectTimeout = 2 * time.Second
|
||||
|
||||
func isPiped() bool {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
@@ -61,6 +66,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
|
||||
var finalJsonErr error
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
// we're look for an event, but it may be in multiple lines, so if json parsing fails
|
||||
// we'll try the next line until we're successful
|
||||
@@ -75,6 +81,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
finalJsonErr = nil
|
||||
|
||||
if !yield(stdinEvent) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -82,8 +89,14 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
yield("{}")
|
||||
if !yield("{}") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if finalJsonErr != nil {
|
||||
@@ -94,15 +107,23 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
|
||||
func getStdinLinesOrBlank() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
if !yield(stdinLine) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
yield("")
|
||||
if !yield("") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,24 +189,17 @@ func connectToAllRelays(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
relayUrls []string,
|
||||
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error), // if this exists we will force preauth
|
||||
opts nostr.PoolOptions,
|
||||
) []*nostr.Relay {
|
||||
// first pass to check if these are valid relay URLs
|
||||
for _, url := range relayUrls {
|
||||
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
|
||||
for i, url := range relayUrls {
|
||||
url = nostr.NormalizeURL(url)
|
||||
relayUrls[i] = url
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
log("invalid relay URL: %s\n", url)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
|
||||
opts.EventMiddleware = sys.TrackEventHints
|
||||
opts.PenaltyBox = true
|
||||
opts.RelayOptions = nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}},
|
||||
}
|
||||
sys.Pool = nostr.NewPool(opts)
|
||||
|
||||
relays := make([]*nostr.Relay, 0, len(relayUrls))
|
||||
|
||||
if supportsDynamicMultilineMagic() {
|
||||
@@ -207,21 +221,28 @@ func connectToAllRelays(
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(relayUrls))
|
||||
mu := sync.Mutex{} // guards lines and relays, written from the goroutines below
|
||||
for i, url := range relayUrls {
|
||||
lines[i] = make([][]byte, 1, 2)
|
||||
logthis := func(s string, args ...any) {
|
||||
mu.Lock()
|
||||
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
|
||||
render()
|
||||
mu.Unlock()
|
||||
}
|
||||
colorizepreamble := func(c func(string, ...any) string) {
|
||||
mu.Lock()
|
||||
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
|
||||
mu.Unlock()
|
||||
}
|
||||
colorizepreamble(color.CyanString)
|
||||
|
||||
go func() {
|
||||
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
|
||||
relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis)
|
||||
if relay != nil {
|
||||
mu.Lock()
|
||||
relays = append(relays, relay)
|
||||
mu.Unlock()
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -230,8 +251,8 @@ func connectToAllRelays(
|
||||
} else {
|
||||
// simple flow
|
||||
for _, url := range relayUrls {
|
||||
log("connecting to %s... ", color.CyanString(url))
|
||||
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
|
||||
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
|
||||
relay := connectToSingleRelay(ctx, c, url, nil, log)
|
||||
if relay != nil {
|
||||
relays = append(relays, relay)
|
||||
}
|
||||
@@ -246,67 +267,78 @@ func connectToSingleRelay(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
url string,
|
||||
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error),
|
||||
colorizepreamble func(c func(string, ...any) string),
|
||||
logthis func(s string, args ...any),
|
||||
) *nostr.Relay {
|
||||
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
|
||||
if preAuthSigner != nil {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(color.YellowString)
|
||||
}
|
||||
logthis("waiting for auth challenge... ")
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
nm := nostr.NormalizeURL(url)
|
||||
|
||||
for range 5 {
|
||||
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
challengeTag := authEvent.Tags.Find("challenge")
|
||||
if challengeTag[1] == "" {
|
||||
return fmt.Errorf("auth not received yet *****") // what a giant hack
|
||||
}
|
||||
return preAuthSigner(ctx, c, logthis, authEvent)
|
||||
}); err == nil {
|
||||
// auth succeeded
|
||||
goto preauthSuccess
|
||||
} else {
|
||||
// auth failed
|
||||
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
|
||||
// it failed because we didn't receive the challenge yet, so keep waiting
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
// it failed for some other reason, so skip this relay
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis(err.Error())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
relay, ok := sys.Pool.Relays.Load(nm)
|
||||
if !ok || relay == nil || !relay.IsConnected() {
|
||||
connectCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
if relay, err = nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions); err != nil {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis("failed to get an AUTH challenge in enough time.")
|
||||
logthis(clampError(err, len(url)+12))
|
||||
return nil
|
||||
}
|
||||
|
||||
preauthSuccess:
|
||||
sys.Pool.Relays.Store(nm, relay)
|
||||
go func(r *nostr.Relay, relayURL string) {
|
||||
<-r.Context().Done()
|
||||
if current, ok := sys.Pool.Relays.Load(relayURL); ok && current == r {
|
||||
sys.Pool.Relays.Delete(relayURL)
|
||||
}
|
||||
}(relay, nm)
|
||||
}
|
||||
|
||||
if c.Bool("force-pre-auth") {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.successf)
|
||||
colorizepreamble(color.YellowString)
|
||||
}
|
||||
logthis("waiting for auth challenge... ")
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
for range 5 {
|
||||
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
challengeTag := authEvent.Tags.Find("challenge")
|
||||
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
|
||||
return fmt.Errorf("auth not received yet *****") // what a giant hack
|
||||
}
|
||||
return authSigner(ctx, c, logthis, authEvent)
|
||||
}); err == nil {
|
||||
// auth succeeded
|
||||
goto preauthSuccess
|
||||
} else {
|
||||
// auth failed
|
||||
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
logthis(err.Error())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
logthis("ok.")
|
||||
return relay
|
||||
} else {
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.errorf)
|
||||
}
|
||||
|
||||
// if we're here that means we've failed to connect, this may be a huge message
|
||||
// but we're likely to only be interested in the lowest level error (although we can leave space)
|
||||
logthis(clampError(err, len(url)+12))
|
||||
logthis("failed to get an AUTH challenge in enough time.")
|
||||
return nil
|
||||
}
|
||||
|
||||
preauthSuccess:
|
||||
if colorizepreamble != nil {
|
||||
colorizepreamble(colors.successf)
|
||||
}
|
||||
logthis("ok.")
|
||||
return relay
|
||||
}
|
||||
|
||||
func clearLines(lineCount int) {
|
||||
@@ -338,29 +370,6 @@ func supportsDynamicMultilineMagic() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
|
||||
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
|
||||
}
|
||||
}()
|
||||
|
||||
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
|
||||
return fmt.Errorf("auth required, but --auth flag not given")
|
||||
}
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
|
||||
|
||||
return kr.SignEvent(ctx, authEvent)
|
||||
}
|
||||
|
||||
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
|
||||
log(msg+"\n", args...)
|
||||
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
|
||||
@@ -415,19 +424,6 @@ func clampError(err error, prefixAlreadyPrinted int) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func appendUnique[A comparable](list []A, newEls ...A) []A {
|
||||
ex:
|
||||
for _, newEl := range newEls {
|
||||
for _, el := range list {
|
||||
if el == newEl {
|
||||
continue ex
|
||||
}
|
||||
}
|
||||
list = append(list, newEl)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func askConfirmation(msg string) bool {
|
||||
if isPiped() {
|
||||
tty, err := tty.Open()
|
||||
@@ -472,15 +468,14 @@ func askConfirmation(msg string) bool {
|
||||
}
|
||||
|
||||
func parsePubKey(value string) (nostr.PubKey, error) {
|
||||
// try nip05 first
|
||||
if nip05.IsValidIdentifier(value) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
pp, err := nip05.QueryIdentifier(ctx, value)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return pp.PublicKey, nil
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, err
|
||||
}
|
||||
// if nip05 fails, fall through to try as pubkey
|
||||
return pp.PublicKey, nil
|
||||
}
|
||||
|
||||
pk, err := nostr.PubKeyFromHex(value)
|
||||
@@ -504,6 +499,19 @@ func parsePubKey(value string) (nostr.PubKey, error) {
|
||||
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
|
||||
}
|
||||
|
||||
func parseSecretKey(input string) (nostr.SecretKey, error) {
|
||||
if prefix, ski, err := nip19.Decode(input); err == nil && prefix == "nsec" {
|
||||
return ski.(nostr.SecretKey), nil
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(input)
|
||||
if err != nil {
|
||||
return nostr.SecretKey{}, fmt.Errorf("invalid secret key: %w", err)
|
||||
}
|
||||
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
func parseEventID(value string) (nostr.ID, error) {
|
||||
id, err := nostr.IDFromHex(value)
|
||||
if err == nil {
|
||||
@@ -513,8 +521,8 @@ func parseEventID(value string) (nostr.ID, error) {
|
||||
if prefix, decoded, err := nip19.Decode(value); err == nil {
|
||||
switch prefix {
|
||||
case "note":
|
||||
if id, ok := decoded.(nostr.ID); ok {
|
||||
return id, nil
|
||||
if event, ok := decoded.(nostr.EventPointer); ok {
|
||||
return event.ID, nil
|
||||
}
|
||||
case "nevent":
|
||||
if event, ok := decoded.(nostr.EventPointer); ok {
|
||||
@@ -526,8 +534,23 @@ func parseEventID(value string) (nostr.ID, error) {
|
||||
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
|
||||
}
|
||||
|
||||
func decodeTagValue(value string) string {
|
||||
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
|
||||
func decodeTagValue(value string, letter rune) string {
|
||||
letter = unicode.ToLower(letter)
|
||||
|
||||
if letter == 'p' {
|
||||
if nip05.IsValidIdentifier(value) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
pp, err := nip05.QueryIdentifier(ctx, value)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return pp.PublicKey.Hex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
|
||||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
|
||||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
|
||||
if ptr, err := nip19.ToPointer(value); err == nil {
|
||||
return ptr.AsTagReference()
|
||||
}
|
||||
@@ -535,24 +558,167 @@ func decodeTagValue(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
|
||||
fullpath := filepath.Join(os.TempDir(), filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
if wipe {
|
||||
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("failed to remove temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmp.WriteString(initialContent); err != nil {
|
||||
tmp.Close()
|
||||
return "", fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
editor := strings.TrimSpace(os.Getenv("VISUAL"))
|
||||
if editor == "" {
|
||||
editor = strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
}
|
||||
if editor == "" {
|
||||
editor = "edit"
|
||||
}
|
||||
|
||||
parts := strings.Fields(editor)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
|
||||
}
|
||||
|
||||
args := append(parts[1:], tmp.Name())
|
||||
cmd := exec.Command(parts[0], args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("editor command failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tmp.Name())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read edited temp file: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func clampWithEllipsis(s string, size int) string {
|
||||
if len(s) <= size {
|
||||
return s
|
||||
}
|
||||
return s[0:size-1] + "…"
|
||||
}
|
||||
|
||||
var (
|
||||
schemaURI string
|
||||
fetchSchemaOnce sync.Once
|
||||
schemaCache schema.Schema
|
||||
schemaErrCache error
|
||||
)
|
||||
|
||||
func getSchema() (schema.Schema, error) {
|
||||
fetchSchemaOnce.Do(func() {
|
||||
if strings.HasPrefix(schemaURI, "http") {
|
||||
schemaCache, schemaErrCache = schema.FetchSchemaFromURL(schemaURI)
|
||||
} else {
|
||||
schemaCache, schemaErrCache = schema.NewSchemaFromFile(schemaURI)
|
||||
}
|
||||
})
|
||||
return schemaCache, schemaErrCache
|
||||
}
|
||||
|
||||
func stringToKind(value string) (nostr.Kind, error) {
|
||||
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
|
||||
if n > 65535 {
|
||||
return 0, fmt.Errorf("kind number %d is out of range (0-65535)", n)
|
||||
}
|
||||
return nostr.Kind(n), nil
|
||||
}
|
||||
|
||||
// find kind from name
|
||||
sch, err := getSchema()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
fuzzyWords := make([]string, 0, len(sch.Kinds))
|
||||
fuzzyIndexes := make([]string, 0, len(sch.Kinds))
|
||||
|
||||
// exact match
|
||||
for k, ks := range sch.Kinds {
|
||||
fuzzyWords = append(fuzzyWords, ks.Description)
|
||||
fuzzyIndexes = append(fuzzyIndexes, k)
|
||||
|
||||
if strings.EqualFold(ks.Description, value) {
|
||||
return ks.Kind, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzy match
|
||||
result := fuzzy.RankFindNormalizedFold(value, fuzzyWords)
|
||||
bestDesc := "<none>"
|
||||
bestDist := "-"
|
||||
|
||||
if len(result) > 0 {
|
||||
if bd := result[0].Distance; bd < 26 {
|
||||
return sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Kind, nil
|
||||
} else {
|
||||
bestDesc = sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Description
|
||||
bestDist = strconv.Itoa(bd)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown kind: %q (closest: %q, distance: %s)", value, bestDesc, bestDist)
|
||||
}
|
||||
|
||||
var colors = struct {
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
italicf func(string, ...any) string
|
||||
bold func(...any) string
|
||||
boldf func(string, ...any) string
|
||||
error func(...any) string
|
||||
errorf func(string, ...any) string
|
||||
success func(...any) string
|
||||
successf func(string, ...any) string
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
italicf func(string, ...any) string
|
||||
bold func(...any) string
|
||||
boldf func(string, ...any) string
|
||||
underline func(...any) string
|
||||
underlinef func(string, ...any) string
|
||||
error func(...any) string
|
||||
errorf func(string, ...any) string
|
||||
success func(...any) string
|
||||
successf func(string, ...any) string
|
||||
}{
|
||||
color.New(color.Reset).Print,
|
||||
color.New(color.Italic).Sprint,
|
||||
color.New(color.Italic).Sprintf,
|
||||
color.New(color.Bold).Sprint,
|
||||
color.New(color.Bold).Sprintf,
|
||||
color.New(color.Underline).Sprint,
|
||||
color.New(color.Underline).Sprintf,
|
||||
color.New(color.Bold, color.FgHiRed).Sprint,
|
||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||
color.New(color.Bold, color.FgHiGreen).Sprintf,
|
||||
}
|
||||
|
||||
func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag {
|
||||
total := 0
|
||||
for _, s := range flagSlices {
|
||||
total += len(s)
|
||||
}
|
||||
total += len(extraFlags)
|
||||
result := make([]cli.Flag, 0, total)
|
||||
for _, s := range flagSlices {
|
||||
result = append(result, s...)
|
||||
}
|
||||
result = append(result, extraFlags...)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,45 +2,35 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/nip46"
|
||||
"fiatjaf.com/nostr/nip49"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/denisbrodbeck/machineid"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mattn/go-tty"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var defaultKey = nostr.KeyOne.Hex()
|
||||
// the default secret key we will use when "--sec" is not provided
|
||||
func defaultKey() nostr.SecretKey {
|
||||
mid, err := machineid.ID()
|
||||
if err != nil {
|
||||
k := nostr.SecretKey{}
|
||||
k[29] = 'n'
|
||||
k[30] = 'a'
|
||||
k[31] = 'k'
|
||||
return k
|
||||
}
|
||||
|
||||
var defaultKeyFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
|
||||
DefaultText: "the key '01'",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
|
||||
Value: defaultKey,
|
||||
HideDefault: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "prompt-sec",
|
||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "connect-as",
|
||||
Usage: "private key to use when communicating with nip46 bunkers",
|
||||
DefaultText: "a random key",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
|
||||
},
|
||||
return sha256.Sum256([]byte(mid))
|
||||
}
|
||||
|
||||
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
|
||||
@@ -50,10 +40,10 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
|
||||
}
|
||||
|
||||
var kr nostr.Keyer
|
||||
if bunker != nil {
|
||||
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
|
||||
} else {
|
||||
if bunker == nil {
|
||||
kr = keyer.NewPlainKeySigner(key)
|
||||
} else {
|
||||
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
|
||||
}
|
||||
|
||||
return kr, key, nil
|
||||
@@ -61,25 +51,15 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
|
||||
|
||||
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
|
||||
sec := c.String("sec")
|
||||
|
||||
if strings.HasPrefix(sec, "bunker://") {
|
||||
// it's a bunker
|
||||
bunkerURL := sec
|
||||
clientKeyHex := c.String("connect-as")
|
||||
var clientKey nostr.SecretKey
|
||||
|
||||
if clientKeyHex != "" {
|
||||
var err error
|
||||
clientKey, err = nostr.SecretKeyFromHex(clientKeyHex)
|
||||
if err != nil {
|
||||
return nostr.SecretKey{}, nil, fmt.Errorf("bunker client key '%s' is invalid: %w", clientKeyHex, err)
|
||||
}
|
||||
} else {
|
||||
clientKey = nostr.Generate()
|
||||
}
|
||||
|
||||
clientKey := getSecretKey(c, "connect-as")
|
||||
logverbose("[nip46]: connecting to %s with client key %s\n", bunkerURL, clientKey.Hex())
|
||||
|
||||
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
|
||||
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, sys.Pool, func(s string) {
|
||||
log(color.CyanString("[nip46]: open the following URL: %s"), s)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -105,16 +85,37 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
|
||||
return sk, nil, nil
|
||||
}
|
||||
|
||||
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
|
||||
return ski.(nostr.SecretKey), nil, nil
|
||||
sk, err := parseSecretKey(sec)
|
||||
return sk, nil, err
|
||||
}
|
||||
|
||||
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
|
||||
log("%s auth failed: %s\n", colors.errorf(cleanUrl), err)
|
||||
}
|
||||
}()
|
||||
|
||||
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
|
||||
return fmt.Errorf("auth required, but --auth flag not given")
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
return nostr.SecretKey{}, nil, fmt.Errorf("invalid secret key: %w", err)
|
||||
var kr nostr.Keyer
|
||||
|
||||
if nip46.IsBunkerClientOperation(ctx) {
|
||||
kr = keyer.NewPlainKeySigner(getSecretKey(c, "connect-as"))
|
||||
} else {
|
||||
kr, _, err = gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sk, nil, nil
|
||||
pk, _ := kr.GetPublicKey(ctx)
|
||||
log("authenticating as %s... ", color.YellowString("%s…", pk.Hex()[0:16]))
|
||||
|
||||
return kr.SignEvent(ctx, authEvent)
|
||||
}
|
||||
|
||||
func promptDecrypt(ncryptsec string) (nostr.SecretKey, error) {
|
||||
|
||||
73
install.sh
Executable file
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
|
||||
}
|
||||
}
|
||||
141
key.go
141
key.go
@@ -23,9 +23,12 @@ var key = &cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
generate,
|
||||
public,
|
||||
expand,
|
||||
encryptKey,
|
||||
decryptKey,
|
||||
combine,
|
||||
validate,
|
||||
defaultCommand,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,7 +57,21 @@ var public = &cli.Command{
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
|
||||
args := c.Args().Slice()
|
||||
if len(args) == 0 && !isPiped() {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
stdout(pk.Hex())
|
||||
return nil
|
||||
}
|
||||
|
||||
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, args) {
|
||||
_, pk := btcec.PrivKeyFromBytes(sk[:])
|
||||
|
||||
if c.Bool("with-parity") {
|
||||
@@ -67,6 +84,37 @@ var public = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var expand = &cli.Command{
|
||||
Name: "expand",
|
||||
Usage: "left-pads a hex key to 64 characters",
|
||||
Description: ``,
|
||||
ArgsUsage: "[key]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
keyhex = strings.TrimSpace(keyhex)
|
||||
if keyhex == "" {
|
||||
continue
|
||||
}
|
||||
if len(keyhex) > 64 {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
|
||||
continue
|
||||
}
|
||||
check := keyhex
|
||||
if len(check)%2 == 1 {
|
||||
check = "0" + check
|
||||
}
|
||||
if _, err := hex.DecodeString(check); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
keyhex = strings.ToLower(keyhex)
|
||||
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var encryptKey = &cli.Command{
|
||||
Name: "encrypt",
|
||||
Usage: "encrypts a secret key and prints an ncryptsec code",
|
||||
@@ -139,7 +187,8 @@ var decryptKey = &cli.Command{
|
||||
}
|
||||
} else {
|
||||
password = c.Args().Get(0)
|
||||
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
|
||||
// the ncryptsec codes come from stdin
|
||||
for ncryptsec := range getStdinLinesOrArgumentsFromSlice(nil) {
|
||||
sk, err := nip49.Decrypt(ncryptsec, password)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
|
||||
@@ -147,6 +196,7 @@ var decryptKey = &cli.Command{
|
||||
}
|
||||
stdout(sk.Hex())
|
||||
}
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
@@ -155,6 +205,64 @@ var decryptKey = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var validate = &cli.Command{
|
||||
Name: "validate",
|
||||
Usage: "validates a public key by attempting to parse it",
|
||||
Description: `Accepts public keys in hex (64 characters) or npub format.
|
||||
Returns error if key is invalid, otherwise exits successfully.`,
|
||||
ArgsUsage: "[pubkey...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
pk = strings.TrimSpace(pk)
|
||||
if pk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var pkBytes []byte
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(pk, "npub1") {
|
||||
_, data, err := nip19.Decode(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
|
||||
continue
|
||||
}
|
||||
tmp := data.(nostr.PubKey)
|
||||
pkBytes = tmp[:]
|
||||
} else {
|
||||
pkBytes, err = hex.DecodeString(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex: %s", err)
|
||||
continue
|
||||
}
|
||||
if len(pkBytes) != 32 {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey length: expected 32 bytes, got %d", len(pkBytes))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, err = btcec.ParsePubKey(append([]byte{0x02}, pkBytes...))
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var defaultCommand = &cli.Command{
|
||||
Name: "default",
|
||||
Usage: "prints the default secret key",
|
||||
Description: `the default secret key is generated differently for each machine (or falls back to a hardcoded one in case of failure), it is not save in any way, but this command makes it available if that is needed.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
stdout(nip19.EncodeNsec(defaultKey()))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var combine = &cli.Command{
|
||||
Name: "combine",
|
||||
Usage: "combines two or more pubkeys using musig2",
|
||||
@@ -183,15 +291,13 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
|
||||
|
||||
for i, keyhex := range result.Keys {
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
pk32, err := parsePubKey(keyhex)
|
||||
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
|
||||
result.Keys[i] = pk32.Hex()
|
||||
|
||||
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
|
||||
group := make([]*btcec.PublicKey, 2)
|
||||
for i, prefix := range []byte{0x02, 0x03} {
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
|
||||
if err != nil {
|
||||
log("error parsing key %s: %s", keyhex, err)
|
||||
continue
|
||||
@@ -199,7 +305,16 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
group[i] = pubk
|
||||
}
|
||||
keyGroups = append(keyGroups, group)
|
||||
} else /* assume it's 33 */ {
|
||||
continue
|
||||
}
|
||||
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
if len(keyb) == 33 {
|
||||
result.Keys[i] = hex.EncodeToString(keyb[1:])
|
||||
|
||||
pubk, err := btcec.ParsePubKey(keyb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
@@ -208,7 +323,15 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
|
||||
// remove the leading byte from the output just so it is all uniform
|
||||
result.Keys[i] = result.Keys[i][2:]
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid key %s: expected 32 or 33 bytes hex", keyhex)
|
||||
}
|
||||
|
||||
if len(keyGroups) < 2 {
|
||||
return fmt.Errorf("at least two pubkeys are required")
|
||||
}
|
||||
|
||||
result.Combinations = make([]Combination, 0, 16)
|
||||
|
||||
64
kind.go
Normal file
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
|
||||
},
|
||||
}
|
||||
45
lmdb.go
45
lmdb.go
@@ -16,28 +16,31 @@ import (
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
configPath := c.String("config-path")
|
||||
if configPath != "" {
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
_, err := lmdbh.NewLMDBHints(hintsPath)
|
||||
if err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
}
|
||||
if configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
if hdb, err := lmdbh.NewLMDBHints(hintsPath); err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
} else {
|
||||
sys.Hints = hdb
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
}
|
||||
|
||||
179
main.go
179
main.go
@@ -6,13 +6,56 @@ import (
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var authFlags = []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
Category: CATEGORY_AUTH,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force-pre-auth",
|
||||
Aliases: []string{"fpa"},
|
||||
Usage: "after connecting, wait for a nip42 \"AUTH\" message to be received, act on it and only then send the query",
|
||||
Category: CATEGORY_AUTH,
|
||||
},
|
||||
}
|
||||
|
||||
var defaultKeyFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sec",
|
||||
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
|
||||
Value: defaultKey().Hex(),
|
||||
DefaultText: "a default key specific to your machine, see it with `nak key default`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "prompt-sec",
|
||||
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Hidden: true,
|
||||
},
|
||||
&SecretKeyFlag{
|
||||
Name: "connect-as",
|
||||
Usage: "private key to use when communicating with nip46 bunkers",
|
||||
Category: CATEGORY_SIGNER,
|
||||
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
|
||||
Value: defaultKey(),
|
||||
DefaultText: "the default key (see `nak key default`)",
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
version string = "debug"
|
||||
isVerbose bool = false
|
||||
@@ -33,6 +76,7 @@ var app = &cli.Command{
|
||||
decode,
|
||||
encode,
|
||||
key,
|
||||
kindCmd,
|
||||
verify,
|
||||
relay,
|
||||
admin,
|
||||
@@ -49,65 +93,96 @@ var app = &cli.Command{
|
||||
curl,
|
||||
fsCmd,
|
||||
publish,
|
||||
nsite,
|
||||
git,
|
||||
group,
|
||||
podcast,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
profile,
|
||||
validateCmd,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "config-path",
|
||||
Hidden: true,
|
||||
Value: (func() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config/nak")
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
})(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
||||
Aliases: []string{"q"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
q := c.Count("quiet")
|
||||
if q >= 1 {
|
||||
log = func(msg string, args ...any) {}
|
||||
if q >= 2 {
|
||||
stdout = func(_ ...any) {}
|
||||
Flags: combineFlags([][]cli.Flag{
|
||||
{
|
||||
&cli.StringFlag{
|
||||
Name: "config-path",
|
||||
Hidden: true,
|
||||
Value: (func() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config/nak")
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "quiet",
|
||||
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
|
||||
Aliases: []string{"q"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
q := c.Count("quiet")
|
||||
if q >= 1 {
|
||||
log = func(msg string, args ...any) {}
|
||||
if q >= 2 {
|
||||
stdout = func(_ ...any) {}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Usage: "print more stuff than normally",
|
||||
Aliases: []string{"v"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
v := c.Count("verbose")
|
||||
if v >= 1 {
|
||||
logverbose = log
|
||||
isVerbose = true
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "connect-timeout",
|
||||
Usage: "timeout for connecting to relays",
|
||||
Value: connectTimeout,
|
||||
Action: func(ctx context.Context, c *cli.Command, d time.Duration) error {
|
||||
connectTimeout = d
|
||||
return nil
|
||||
},
|
||||
Hidden: true,
|
||||
},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Usage: "print more stuff than normally",
|
||||
Aliases: []string{"v"},
|
||||
Action: func(ctx context.Context, c *cli.Command, b bool) error {
|
||||
v := c.Count("verbose")
|
||||
if v >= 1 {
|
||||
logverbose = log
|
||||
isVerbose = true
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultKeyFlags,
|
||||
authFlags,
|
||||
}),
|
||||
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||
sys = sdk.NewSystem()
|
||||
|
||||
setupLocalDatabases(c, sys)
|
||||
|
||||
sys.Pool = nostr.NewPool(nostr.PoolOptions{
|
||||
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
|
||||
EventMiddleware: sys.TrackEventHints,
|
||||
RelayOptions: nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
|
||||
},
|
||||
})
|
||||
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
|
||||
sys.Pool.EventMiddleware = sys.TrackEventHints
|
||||
sys.Pool.RelayOptions = nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
|
||||
}
|
||||
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {
|
||||
if strings.HasPrefix(s, "authenticating as") {
|
||||
cleanUrl, _ := strings.CutPrefix(
|
||||
nip42.GetRelayURLFromAuthEvent(*authEvent),
|
||||
"wss://",
|
||||
)
|
||||
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
|
||||
}
|
||||
log(s+"\n", args...)
|
||||
}, authEvent)
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
},
|
||||
@@ -118,6 +193,12 @@ func init() {
|
||||
Name: "version",
|
||||
Usage: "prints the version",
|
||||
}
|
||||
cli.HelpFlag = &cli.BoolFlag{
|
||||
Name: "help",
|
||||
Usage: "shows help",
|
||||
HideDefault: true,
|
||||
Local: true,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -126,9 +207,7 @@ func main() {
|
||||
// a megahack to enable this curl command proxy
|
||||
if len(os.Args) > 2 && os.Args[1] == "curl" {
|
||||
if err := realCurl(); err != nil {
|
||||
if err != nil {
|
||||
log(color.YellowString(err.Error()) + "\n")
|
||||
}
|
||||
log(color.YellowString(err.Error()) + "\n")
|
||||
colors.reset()
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -136,9 +215,7 @@ func main() {
|
||||
}
|
||||
|
||||
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||
if err != nil {
|
||||
log("%s\n", color.RedString(err.Error()))
|
||||
}
|
||||
log("%s\n", color.RedString(err.Error()))
|
||||
colors.reset()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
156
mcp.go
156
mcp.go
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -18,9 +19,7 @@ var mcpServer = &cli.Command{
|
||||
Usage: "pander to the AI gods",
|
||||
Description: ``,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
),
|
||||
Flags: []cli.Flag{},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
s := server.NewMCPServer(
|
||||
"nak",
|
||||
@@ -33,10 +32,10 @@ var mcpServer = &cli.Command{
|
||||
}
|
||||
|
||||
s.AddTool(mcp.NewTool("publish_note",
|
||||
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
|
||||
mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
|
||||
mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
|
||||
mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
|
||||
mcp.WithDescription("publish a short note event to Nostr with the given text content"),
|
||||
mcp.WithString("content", mcp.Description("arbitrary string to be published"), mcp.Required()),
|
||||
mcp.WithString("relay", mcp.Description("relay to publish the note to")),
|
||||
mcp.WithString("mention", mcp.Description("nostr user's public key to be mentioned")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
content := required[string](r, "content")
|
||||
mention, _ := optional[string](r, "mention")
|
||||
@@ -105,7 +104,7 @@ var mcpServer = &cli.Command{
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("resolve_nostr_uri",
|
||||
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
|
||||
mcp.WithDescription("resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
|
||||
mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
uri := required[string](r, "uri")
|
||||
@@ -136,56 +135,70 @@ var mcpServer = &cli.Command{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
|
||||
return mcp.NewToolResultError("couldn't find this event anywhere"), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(
|
||||
fmt.Sprintf("this is a Nostr event: %s", event),
|
||||
), nil
|
||||
case "naddr":
|
||||
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
|
||||
return mcp.NewToolResultError("for now we can't handle this kind of Nostr uri"), nil
|
||||
default:
|
||||
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
|
||||
return mcp.NewToolResultError("we don't know how to handle this Nostr uri"), nil
|
||||
}
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("search_profile",
|
||||
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
|
||||
mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
|
||||
mcp.WithNumber("limit", mcp.Description("How many results to return")),
|
||||
mcp.WithDescription("search for the public key of a Nostr user given their name"),
|
||||
mcp.WithString("name", mcp.Description("name to be searched"), mcp.Required()),
|
||||
mcp.WithNumber("limit", mcp.Description("how many results to return")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
name := required[string](r, "name")
|
||||
limit, _ := optional[float64](r, "limit")
|
||||
|
||||
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
|
||||
if limit > 0 {
|
||||
filter.Limit = int(limit)
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
res := strings.Builder{}
|
||||
res.WriteString("Search results: ")
|
||||
l := 0
|
||||
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
pm, _ := sdk.ParseMetadata(result.Event)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
res.Grow(500)
|
||||
res.WriteString("search results: ")
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
// check if input is already a valid pubkey
|
||||
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
|
||||
pm := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
} else {
|
||||
// otherwise try to search
|
||||
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
|
||||
if limit > 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
l := 0
|
||||
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
pm, _ := sdk.ParseMetadata(result.Event)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
|
||||
}
|
||||
}
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("Couldn't find anyone with that name."), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(res.String()), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
|
||||
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
|
||||
mcp.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
|
||||
mcp.WithDescription("get the best relay from where to read notes from a specific Nostr user"),
|
||||
mcp.WithString("pubkey", mcp.Description("public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
|
||||
if err != nil {
|
||||
@@ -197,7 +210,7 @@ var mcpServer = &cli.Command{
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("read_events_from_relay",
|
||||
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
|
||||
mcp.WithDescription("makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
|
||||
mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
|
||||
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
|
||||
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
|
||||
@@ -229,7 +242,7 @@ var mcpServer = &cli.Command{
|
||||
for ie := range events {
|
||||
result.WriteString("author public key: ")
|
||||
result.WriteString(ie.PubKey.Hex())
|
||||
result.WriteString("content: '")
|
||||
result.WriteString("\ncontent: '")
|
||||
result.WriteString(ie.Content)
|
||||
result.WriteString("'")
|
||||
result.WriteString("\n---\n")
|
||||
@@ -238,6 +251,77 @@ var mcpServer = &cli.Command{
|
||||
return mcp.NewToolResultText(result.String()), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("search_events",
|
||||
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
|
||||
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
|
||||
mcp.WithString("author", mcp.Description("author public key to filter by")),
|
||||
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
|
||||
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
|
||||
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
search := required[string](r, "search")
|
||||
author, hasAuthor := optional[string](r, "author")
|
||||
kind, hasKind := optional[float64](r, "kind")
|
||||
limit, _ := optional[float64](r, "limit")
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
filter := nostr.Filter{Search: search, Limit: int(limit)}
|
||||
if hasKind {
|
||||
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
|
||||
}
|
||||
|
||||
var relays []string
|
||||
|
||||
if hasAuthor {
|
||||
if pk, err := nostr.PubKeyFromHex(author); err != nil {
|
||||
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
|
||||
} else {
|
||||
filter.Authors = append(filter.Authors, pk)
|
||||
}
|
||||
|
||||
pk, _ := nostr.PubKeyFromHex(author)
|
||||
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
|
||||
|
||||
for _, relayURL := range writeRelays {
|
||||
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
|
||||
for _, nip := range info.SupportedNIPs {
|
||||
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
|
||||
relays = append(relays, relayURL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(relays) == 0 {
|
||||
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
|
||||
}
|
||||
|
||||
result := strings.Builder{}
|
||||
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
|
||||
|
||||
l := 0
|
||||
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
|
||||
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("no events found matching the search criteria."), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(result.String()), nil
|
||||
})
|
||||
|
||||
return server.ServeStdio(s)
|
||||
},
|
||||
}
|
||||
|
||||
18
nip.go
18
nip.go
@@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type nipInfo struct {
|
||||
@@ -63,7 +65,6 @@ examples:
|
||||
}
|
||||
|
||||
url := "https://github.com/nostr-protocol/nips/blob/master/" + foundLink
|
||||
fmt.Println("Opening " + url)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
@@ -129,12 +130,23 @@ examples:
|
||||
}
|
||||
|
||||
// render markdown
|
||||
rendered, err := glamour.Render(string(body), "auto")
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width < 80 {
|
||||
width = 120
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create renderer: %w", err)
|
||||
}
|
||||
rendered, err := r.Render(string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render markdown: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print(rendered)
|
||||
stdout(rendered)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
56
nostrfs/asyncfile.go
Normal file
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
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
//go:build !openbsd
|
||||
|
||||
package main
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -19,18 +17,18 @@ import (
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
type FSOptions struct {
|
||||
type Options struct {
|
||||
AutoPublishNotesTimeout time.Duration
|
||||
AutoPublishArticlesTimeout time.Duration
|
||||
}
|
||||
|
||||
type FSRoot struct {
|
||||
type NostrRoot struct {
|
||||
fuse.FileSystemBase
|
||||
ctx context.Context
|
||||
sys *sdk.System
|
||||
rootPubKey nostr.PubKey
|
||||
signer nostr.Signer
|
||||
opts FSOptions
|
||||
opts Options
|
||||
mountpoint string
|
||||
|
||||
mu sync.RWMutex
|
||||
@@ -53,9 +51,9 @@ type FSNode struct {
|
||||
loaded bool
|
||||
}
|
||||
|
||||
var _ fuse.FileSystemInterface = (*FSRoot)(nil)
|
||||
var _ fuse.FileSystemInterface = (*NostrRoot)(nil)
|
||||
|
||||
func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
|
||||
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot {
|
||||
var system *sdk.System
|
||||
if sys != nil {
|
||||
system = sys.(*sdk.System)
|
||||
@@ -73,7 +71,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
|
||||
|
||||
abs, _ := filepath.Abs(mountpoint)
|
||||
|
||||
root := &FSRoot{
|
||||
root := &NostrRoot{
|
||||
ctx: ctx,
|
||||
sys: system,
|
||||
rootPubKey: pubkey,
|
||||
@@ -103,7 +101,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
|
||||
return root
|
||||
}
|
||||
|
||||
func (r *FSRoot) initialize() {
|
||||
func (r *NostrRoot) initialize() {
|
||||
if r.rootPubKey == nostr.ZeroPK {
|
||||
return
|
||||
}
|
||||
@@ -148,7 +146,7 @@ func (r *FSRoot) initialize() {
|
||||
r.nodes["/"].children["@me"] = meNode
|
||||
}
|
||||
|
||||
func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
||||
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
||||
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
||||
if pm.Event == nil {
|
||||
return
|
||||
@@ -177,7 +175,7 @@ func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
||||
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
||||
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
||||
if pm.Event == nil || pm.Picture == "" {
|
||||
return
|
||||
@@ -258,7 +256,7 @@ func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
||||
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
||||
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
@@ -357,7 +355,7 @@ func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
|
||||
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
|
||||
// use event ID first 8 chars + extension based on kind
|
||||
ext := kindToExtension(evt.Kind)
|
||||
|
||||
@@ -393,14 +391,14 @@ func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
|
||||
return fmt.Sprintf("%s.%s", idHex, ext)
|
||||
}
|
||||
|
||||
func (r *FSRoot) getLog() func(string, ...interface{}) {
|
||||
func (r *NostrRoot) getLog() func(string, ...interface{}) {
|
||||
if log := r.ctx.Value("log"); log != nil {
|
||||
return log.(func(string, ...interface{}))
|
||||
}
|
||||
return func(string, ...interface{}) {}
|
||||
}
|
||||
|
||||
func (r *FSRoot) getNode(path string) *FSNode {
|
||||
func (r *NostrRoot) getNode(path string) *FSNode {
|
||||
originalPath := path
|
||||
|
||||
// normalize path
|
||||
@@ -453,7 +451,7 @@ func (r *FSRoot) getNode(path string) *FSNode {
|
||||
return node
|
||||
}
|
||||
|
||||
func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
||||
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
||||
node := r.getNode(path)
|
||||
|
||||
// if node doesn't exist, try dynamic lookup
|
||||
@@ -482,7 +480,7 @@ func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
||||
}
|
||||
|
||||
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
|
||||
func (r *FSRoot) dynamicLookup(path string) bool {
|
||||
func (r *NostrRoot) dynamicLookup(path string) bool {
|
||||
// normalize path
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
@@ -537,7 +535,7 @@ func (r *FSRoot) dynamicLookup(path string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
|
||||
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
|
||||
dirPath := "/" + npub
|
||||
|
||||
// check if already exists
|
||||
@@ -630,7 +628,7 @@ func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer no
|
||||
go r.fetchProfilePicture(dirPath, pubkey)
|
||||
}
|
||||
|
||||
func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
|
||||
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
|
||||
dirPath := parentPath + "/" + name
|
||||
|
||||
// check if already exists
|
||||
@@ -658,7 +656,7 @@ func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filte
|
||||
go r.fetchEvents(dirPath, filter)
|
||||
}
|
||||
|
||||
func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
|
||||
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
|
||||
dirPath := "/" + name
|
||||
|
||||
// fetch the event
|
||||
@@ -739,7 +737,7 @@ func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) b
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *FSRoot) Readdir(path string,
|
||||
func (r *NostrRoot) Readdir(path string,
|
||||
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
|
||||
ofst int64,
|
||||
fh uint64,
|
||||
@@ -770,7 +768,7 @@ func (r *FSRoot) Readdir(path string,
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *FSRoot) Open(path string, flags int) (int, uint64) {
|
||||
func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
|
||||
// log the open attempt
|
||||
if r.ctx.Value("logverbose") != nil {
|
||||
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
|
||||
@@ -801,7 +799,7 @@ func (r *FSRoot) Open(path string, flags int) (int, uint64) {
|
||||
return 0, node.ino
|
||||
}
|
||||
|
||||
func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
node := r.getNode(path)
|
||||
if node == nil || node.isDir {
|
||||
return -fuse.ENOENT
|
||||
@@ -820,7 +818,7 @@ func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *FSRoot) Opendir(path string) (int, uint64) {
|
||||
func (r *NostrRoot) Opendir(path string) (int, uint64) {
|
||||
node := r.getNode(path)
|
||||
if node == nil {
|
||||
return -fuse.ENOENT, ^uint64(0)
|
||||
@@ -831,16 +829,16 @@ func (r *FSRoot) Opendir(path string) (int, uint64) {
|
||||
return 0, node.ino
|
||||
}
|
||||
|
||||
func (r *FSRoot) Release(path string, fh uint64) int {
|
||||
func (r *NostrRoot) Release(path string, fh uint64) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *FSRoot) Releasedir(path string, fh uint64) int {
|
||||
func (r *NostrRoot) Releasedir(path string, fh uint64) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Create creates a new file
|
||||
func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
||||
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
||||
// parse path
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
@@ -884,7 +882,7 @@ func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
||||
}
|
||||
|
||||
// Truncate truncates a file
|
||||
func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
|
||||
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
|
||||
node := r.getNode(path)
|
||||
if node == nil {
|
||||
return -fuse.ENOENT
|
||||
@@ -913,7 +911,7 @@ func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
|
||||
}
|
||||
|
||||
// Write writes data to a file
|
||||
func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
node := r.getNode(path)
|
||||
if node == nil {
|
||||
return -fuse.ENOENT
|
||||
@@ -957,7 +955,7 @@ func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *FSRoot) publishNote(path string) {
|
||||
func (r *NostrRoot) publishNote(path string) {
|
||||
r.mu.Lock()
|
||||
node, ok := r.nodes[path]
|
||||
if !ok {
|
||||
@@ -998,10 +996,13 @@ func (r *FSRoot) publishNote(path string) {
|
||||
}
|
||||
|
||||
for _, url := range relays {
|
||||
relay, err := r.sys.Pool.EnsureRelay(url)
|
||||
connectCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
relay, err := nostr.RelayConnect(connectCtx, url, r.sys.Pool.RelayOptions)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r.sys.Pool.Relays.Store(nostr.NormalizeURL(url), relay)
|
||||
relay.Publish(ctx, *evt)
|
||||
}
|
||||
|
||||
@@ -1034,7 +1035,7 @@ func (r *FSRoot) publishNote(path string) {
|
||||
}
|
||||
|
||||
// Unlink deletes a file
|
||||
func (r *FSRoot) Unlink(path string) int {
|
||||
func (r *NostrRoot) Unlink(path string) int {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
@@ -1067,7 +1068,7 @@ func (r *FSRoot) Unlink(path string) int {
|
||||
}
|
||||
|
||||
// Mkdir creates a new directory
|
||||
func (r *FSRoot) Mkdir(path string, mode uint32) int {
|
||||
func (r *NostrRoot) Mkdir(path string, mode uint32) int {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
@@ -1109,7 +1110,7 @@ func (r *FSRoot) Mkdir(path string, mode uint32) int {
|
||||
}
|
||||
|
||||
// Rmdir removes a directory
|
||||
func (r *FSRoot) Rmdir(path string) int {
|
||||
func (r *NostrRoot) Rmdir(path string) int {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
@@ -1151,7 +1152,7 @@ func (r *FSRoot) Rmdir(path string) int {
|
||||
}
|
||||
|
||||
// Utimens updates file timestamps
|
||||
func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
|
||||
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
|
||||
node := r.getNode(path)
|
||||
if node == nil {
|
||||
return -fuse.ENOENT
|
||||
338
nsite.go
Normal file
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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
242
publish.go
242
publish.go
@@ -8,26 +8,36 @@ import (
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var publish = &cli.Command{
|
||||
Name: "publish",
|
||||
Usage: "publishes a note with content from stdin",
|
||||
Description: `reads content from stdin and publishes it as a note, optionally as a reply to another note.
|
||||
Usage: "publishes a note with content from stdin or argument",
|
||||
Description: `reads content and publishes it as a note, optionally as a reply to another note.
|
||||
Either pipe content or pass it as the first argument.
|
||||
|
||||
example:
|
||||
echo "hello world" | nak publish
|
||||
nak publish "hello world"
|
||||
echo "I agree!" | nak publish --reply nevent1...
|
||||
echo "tagged post" | nak publish -t t=mytag -t e=someeventid`,
|
||||
nak publish -t t=mytag -t e=someeventid "tagged post"`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also publish to these relays",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reply",
|
||||
Usage: "event id, naddr1 or nevent1 code to reply to",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "comment",
|
||||
Usage: "event id, naddr1 or nevent1 code to comment on (NIP-22 kind 1111, takes precedence over --reply)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
@@ -40,11 +50,6 @@ example:
|
||||
DefaultText: "now",
|
||||
Value: nostr.Now(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "nevent",
|
||||
Usage: "print the nevent code (to stderr) after the event is published",
|
||||
@@ -57,9 +62,17 @@ example:
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read from stdin: %w", err)
|
||||
var content []byte
|
||||
if isPiped() {
|
||||
var err error
|
||||
content, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
} else if firstArg := c.Args().First(); firstArg != "" {
|
||||
content = []byte(firstArg)
|
||||
} else {
|
||||
return fmt.Errorf("no content. pipe or pass content as argument.")
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
@@ -74,48 +87,67 @@ example:
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
}
|
||||
|
||||
// handle reply flag
|
||||
// handle reply/comment flag
|
||||
// --comment takes precedence over --reply
|
||||
target := c.String("comment")
|
||||
isComment := c.IsSet("comment")
|
||||
if target == "" {
|
||||
target = c.String("reply")
|
||||
}
|
||||
var replyRelays []string
|
||||
if replyTo := c.String("reply"); replyTo != "" {
|
||||
var replyEvent *nostr.Event
|
||||
|
||||
// try to decode as nevent or naddr first
|
||||
if strings.HasPrefix(replyTo, "nevent1") || strings.HasPrefix(replyTo, "naddr1") {
|
||||
_, value, err := nip19.Decode(replyTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid reply target: %w", err)
|
||||
}
|
||||
|
||||
switch pointer := value.(type) {
|
||||
case nostr.EventPointer:
|
||||
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
|
||||
case nostr.EntityPointer:
|
||||
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch reply target event: %w", err)
|
||||
}
|
||||
} else {
|
||||
// try as raw event ID
|
||||
id, err := nostr.IDFromHex(replyTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid event id: %w", err)
|
||||
}
|
||||
replyEvent, _, err = sys.FetchSpecificEvent(ctx, nostr.EventPointer{ID: id}, sdk.FetchSpecificEventParameters{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch reply target event: %w", err)
|
||||
}
|
||||
if target != "" {
|
||||
replyEvent, relayHints, err := sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch reply target event: %w", err)
|
||||
}
|
||||
|
||||
if replyEvent.Kind != 1 {
|
||||
if isComment || replyEvent.Kind != 1 {
|
||||
// NIP-22 comment
|
||||
evt.Kind = 1111
|
||||
}
|
||||
buildNIP22Tags(&evt, replyEvent, relayHints)
|
||||
} else {
|
||||
// NIP-10 reply
|
||||
if replyEvent.Kind != 1 {
|
||||
evt.Kind = 1111
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
|
||||
}
|
||||
|
||||
// add reply tags
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
|
||||
nostr.Tag{"p", replyEvent.PubKey.Hex()},
|
||||
)
|
||||
// add reply tags per NIP-10
|
||||
rootID := ""
|
||||
rootRelay := ""
|
||||
for _, tag := range replyEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "e" {
|
||||
if len(tag) >= 4 && tag[3] == "root" {
|
||||
rootID = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootRelay = tag[2]
|
||||
}
|
||||
break
|
||||
}
|
||||
if rootID == "" {
|
||||
rootID = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootRelay = tag[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rootID == "" {
|
||||
// replying to root event
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"e", replyEvent.ID.Hex(), "", "root"},
|
||||
)
|
||||
} else {
|
||||
// replying to a reply
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"e", rootID, rootRelay, "root"},
|
||||
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
|
||||
)
|
||||
}
|
||||
evt.Tags = append(evt.Tags,
|
||||
nostr.Tag{"p", replyEvent.PubKey.Hex()},
|
||||
)
|
||||
}
|
||||
|
||||
replyRelays = sys.FetchInboxRelays(ctx, replyEvent.PubKey, 3)
|
||||
}
|
||||
@@ -150,14 +182,9 @@ example:
|
||||
relayUrls := sys.FetchWriteRelays(ctx, pk)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
||||
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, c.StringSlice("relay")...)
|
||||
|
||||
relays := connectToAllRelays(ctx, c, relayUrls)
|
||||
|
||||
if len(relays) == 0 {
|
||||
if len(relayUrls) == 0 {
|
||||
@@ -179,3 +206,102 @@ example:
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
},
|
||||
}
|
||||
|
||||
func buildNIP22Tags(evt *nostr.Event, target *nostr.Event, relayHints []string) {
|
||||
var relayHint string
|
||||
if len(relayHints) > 0 {
|
||||
relayHint = relayHints[0]
|
||||
}
|
||||
|
||||
dtag := func() string {
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "d" {
|
||||
return tag[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// find root scope in target's uppercase NIP-22 tags
|
||||
var rootScopeName, rootScopeValue, rootScopeRelay, rootKind, rootPubkey string
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && (tag[0] == "E" || tag[0] == "A" || tag[0] == "I") {
|
||||
rootScopeName = tag[0]
|
||||
rootScopeValue = tag[1]
|
||||
if len(tag) >= 3 {
|
||||
rootScopeRelay = tag[2]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if rootScopeName != "" {
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "K" {
|
||||
rootKind = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, tag := range target.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "P" {
|
||||
rootPubkey = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
|
||||
rootScopeName = "A"
|
||||
rootScopeValue = fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
|
||||
} else {
|
||||
rootScopeName = "E"
|
||||
rootScopeValue = target.ID.Hex()
|
||||
}
|
||||
rootScopeRelay = relayHint
|
||||
rootKind = fmt.Sprint(target.Kind)
|
||||
rootPubkey = target.PubKey.Hex()
|
||||
}
|
||||
|
||||
// root scope tag
|
||||
rootScopeTag := nostr.Tag{rootScopeName, rootScopeValue}
|
||||
if rootScopeName == "E" && rootPubkey != "" {
|
||||
// the pubkey goes in the 4th position, so the relay hint must be
|
||||
// present (even if empty) to keep it there
|
||||
rootScopeTag = append(rootScopeTag, rootScopeRelay, rootPubkey)
|
||||
} else if rootScopeRelay != "" {
|
||||
rootScopeTag = append(rootScopeTag, rootScopeRelay)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, rootScopeTag)
|
||||
|
||||
if rootKind != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"K", rootKind})
|
||||
}
|
||||
if rootPubkey != "" && rootScopeName != "I" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"P", rootPubkey})
|
||||
}
|
||||
|
||||
// parent tags (lowercase) - always reference the target event
|
||||
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
|
||||
aValue := fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
|
||||
aTag := nostr.Tag{"a", aValue}
|
||||
if relayHint != "" {
|
||||
aTag = append(aTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, aTag)
|
||||
}
|
||||
eTag := nostr.Tag{"e", target.ID.Hex()}
|
||||
if target.PubKey != (nostr.PubKey{}) {
|
||||
// same here: keep the pubkey in the 4th position
|
||||
eTag = append(eTag, relayHint, target.PubKey.Hex())
|
||||
} else if relayHint != "" {
|
||||
eTag = append(eTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, eTag)
|
||||
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"k", fmt.Sprint(target.Kind)})
|
||||
|
||||
pTag := nostr.Tag{"p", target.PubKey.Hex()}
|
||||
if relayHint != "" {
|
||||
pTag = append(pTag, relayHint)
|
||||
}
|
||||
evt.Tags = append(evt.Tags, pTag)
|
||||
}
|
||||
|
||||
3
relay.go
3
relay.go
@@ -24,13 +24,14 @@ var relay = &cli.Command{
|
||||
|
||||
info, err := nip11.Fetch(ctx, url)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
pretty, _ := json.MarshalIndent(info, "", " ")
|
||||
stdout(string(pretty))
|
||||
}
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
395
req.go
395
req.go
@@ -15,9 +15,8 @@ import (
|
||||
"fiatjaf.com/nostr/eventstore"
|
||||
"fiatjaf.com/nostr/eventstore/slicestore"
|
||||
"fiatjaf.com/nostr/eventstore/wrappers"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"fiatjaf.com/nostr/nip77"
|
||||
"github.com/fatih/color"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -42,64 +41,68 @@ it can also take a filter from stdin, optionally modify it with flags and send i
|
||||
example:
|
||||
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
append(reqFilterFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "only-missing",
|
||||
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ids-only",
|
||||
Usage: "use nip77 to fetch just a list of ids",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep the subscription open, printing all events as they are returned",
|
||||
DefaultText: "false, will close on EOSE",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "use outbox relays from specified public keys",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "outbox-relays-per-pubkey",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "number of outbox relays to use for each pubkey",
|
||||
Value: 3,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "paginate",
|
||||
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
|
||||
DefaultText: "false",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "paginate-interval",
|
||||
Usage: "time between queries when using --paginate",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bare",
|
||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force-pre-auth",
|
||||
Aliases: []string{"fpa"},
|
||||
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "spell",
|
||||
Usage: "output a spell event (kind 777) instead of a filter",
|
||||
},
|
||||
)...,
|
||||
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter returned events with jq expression",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "jq-raw",
|
||||
Usage: "print --jq string results without JSON quoting, like `jq -r`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-verify",
|
||||
Usage: "skip event signature verification from relays",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "only-missing",
|
||||
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ids-only",
|
||||
Usage: "use nip77 to fetch just a list of ids",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep the subscription open, printing all events as they are returned",
|
||||
DefaultText: "false, will close on EOSE",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "outbox-relays-per-pubkey",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "number of outbox relays to use for each pubkey",
|
||||
Value: 3,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "paginate",
|
||||
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
|
||||
DefaultText: "false",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "paginate-interval",
|
||||
Usage: "time between queries when using --paginate",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bare",
|
||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "spell",
|
||||
Usage: "output a spell event (kind 777) instead of a filter",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Bool("no-verify") {
|
||||
sys.Pool.RelayOptions.AssumeValid = true
|
||||
}
|
||||
|
||||
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
|
||||
if negentropy {
|
||||
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
|
||||
@@ -125,32 +128,20 @@ example:
|
||||
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") {
|
||||
return fmt.Errorf("--jq requires relay URLs or --outbox")
|
||||
}
|
||||
|
||||
if len(relayUrls) > 0 && !negentropy {
|
||||
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
|
||||
// connect to all relays we expect to use in this call in parallel
|
||||
forcePreAuthSigner := authSigner
|
||||
if !c.Bool("force-pre-auth") {
|
||||
forcePreAuthSigner = nil
|
||||
}
|
||||
relays := connectToAllRelays(
|
||||
ctx,
|
||||
c,
|
||||
relayUrls,
|
||||
forcePreAuthSigner,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {
|
||||
if strings.HasPrefix(s, "authenticating as") {
|
||||
cleanUrl, _ := strings.CutPrefix(
|
||||
nip42.GetRelayURLFromAuthEvent(*authEvent),
|
||||
"wss://",
|
||||
)
|
||||
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
|
||||
}
|
||||
log(s+"\n", args...)
|
||||
}, authEvent)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// stop here already if all connections failed
|
||||
if len(relays) == 0 {
|
||||
@@ -195,8 +186,11 @@ example:
|
||||
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
|
||||
continue
|
||||
if err := store.SaveEvent(evt); err != nil {
|
||||
if err == eventstore.ErrDupEvent {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to save event from sync file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
@@ -206,6 +200,7 @@ example:
|
||||
|
||||
target := PrintingQuerierPublisher{
|
||||
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
|
||||
jq: jq,
|
||||
}
|
||||
|
||||
var source nostr.Querier = nil
|
||||
@@ -235,7 +230,9 @@ example:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req")
|
||||
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter or spell
|
||||
@@ -277,7 +274,8 @@ func performReq(
|
||||
paginate bool,
|
||||
paginateInterval time.Duration,
|
||||
label string,
|
||||
) {
|
||||
jq jqProcessor,
|
||||
) error {
|
||||
var results chan nostr.RelayEvent
|
||||
var closeds chan nostr.RelayClosed
|
||||
|
||||
@@ -291,6 +289,24 @@ func performReq(
|
||||
} else if outbox {
|
||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||
|
||||
pTagPubkeys := make([]nostr.PubKey, 0, 4)
|
||||
if len(filter.Authors) == 0 && filter.Tags != nil {
|
||||
seen := make(map[nostr.PubKey]struct{})
|
||||
pTags := append([]string{}, filter.Tags["p"]...)
|
||||
pTags = append(pTags, filter.Tags["P"]...)
|
||||
for _, value := range pTags {
|
||||
pubkey, err := parsePubKey(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[pubkey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pubkey] = struct{}{}
|
||||
pTagPubkeys = append(pTagPubkeys, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, relayUrl := range relayUrls {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
@@ -298,54 +314,80 @@ func performReq(
|
||||
})
|
||||
}
|
||||
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
|
||||
// relays for p tags when no authors are given
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
|
||||
for _, pubkey := range pTagPubkeys {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
mu.Lock()
|
||||
if slices.IndexFunc(defs, matchUrl) == -1 {
|
||||
// new relay, add it
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: url,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
idx := slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
// new relay, add it
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
} else {
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
mu.Lock()
|
||||
// check again after locking to prevent races
|
||||
idx = slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
// then add it
|
||||
if idx := slices.IndexFunc(defs, matchUrl); idx == -1 {
|
||||
// new relay, add it
|
||||
filter := filter.Clone()
|
||||
filter.Authors = []nostr.PubKey{pubkey}
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: url,
|
||||
})
|
||||
mu.Unlock()
|
||||
continue // done with this relay url
|
||||
} else {
|
||||
// existing relay, add this pubkey
|
||||
defs[idx].Authors = append(defs[idx].Authors, pubkey)
|
||||
}
|
||||
|
||||
// otherwise we'll just use the idx
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// existing relay, add this pubkey
|
||||
defs[idx].Authors = append(defs[idx].Authors, pubkey)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
}
|
||||
errg.Wait()
|
||||
|
||||
if stream {
|
||||
logverbose("running subscription with %d directed filters...\n", len(defs))
|
||||
@@ -367,25 +409,44 @@ func performReq(
|
||||
readevents:
|
||||
for {
|
||||
select {
|
||||
case ie, ok := <-results:
|
||||
if !ok {
|
||||
case ie, stillOpen := <-results:
|
||||
if !stillOpen {
|
||||
break readevents
|
||||
}
|
||||
stdout(ie.Event)
|
||||
case closed := <-closeds:
|
||||
if closed.HandledAuth {
|
||||
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
v, matches, err := jq(ie.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
|
||||
case closed, stillOpen := <-closeds:
|
||||
if stillOpen {
|
||||
if closed.HandledAuth {
|
||||
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
} else {
|
||||
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break readevents
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var reqFilterFlags = []cli.Flag{
|
||||
&PubKeySliceFlag{
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors",
|
||||
@@ -397,10 +458,10 @@ var reqFilterFlags = []cli.Flag{
|
||||
Usage: "only accept events with these ids",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
&KindSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "only accept events with these kind numbers",
|
||||
Usage: "only accept events with these kind numbers or kind names",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
@@ -424,6 +485,11 @@ var reqFilterFlags = []cli.Flag{
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
@@ -447,38 +513,75 @@ var reqFilterFlags = []cli.Flag{
|
||||
Usage: "a nip50 search query, use it only with relays that explicitly support it",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
|
||||
// hidden
|
||||
&cli.StringFlag{
|
||||
Name: "schema",
|
||||
Usage: "url to download the YAML schema from, or path to the file",
|
||||
Value: schema.DefaultSchemaURL,
|
||||
TakesFile: true,
|
||||
Destination: &schemaURI,
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
type flagTag struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
|
||||
filter.Authors = append(filter.Authors, authors...)
|
||||
tags := make([]flagTag, 0, 5)
|
||||
|
||||
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
|
||||
for _, author := range as {
|
||||
|
||||
// is it an address?
|
||||
if author.Addr != nil {
|
||||
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if author.PubKey != nostr.ZeroPK {
|
||||
filter.Authors = append(filter.Authors, author.PubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ids := getIDSlice(c, "id"); len(ids) > 0 {
|
||||
filter.IDs = append(filter.IDs, ids...)
|
||||
}
|
||||
for _, kind64 := range c.IntSlice("kind") {
|
||||
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
|
||||
if kinds := getKindSlice(c, "kind"); len(kinds) > 0 {
|
||||
filter.Kinds = append(filter.Kinds, kinds...)
|
||||
}
|
||||
if search := c.String("search"); search != "" {
|
||||
filter.Search = search
|
||||
}
|
||||
tags := make([][]string, 0, 5)
|
||||
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
|
||||
val := spl[1]
|
||||
if len(spl[0]) == 1 {
|
||||
val = decodeTagValue(val, rune(spl[0][0]))
|
||||
}
|
||||
tags = append(tags, flagTag{spl[0], val})
|
||||
} else {
|
||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||
}
|
||||
}
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = append(tags, []string{"e", decodeTagValue(etag)})
|
||||
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", decodeTagValue(ptag)})
|
||||
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
tags = append(tags, []string{"d", decodeTagValue(dtag)})
|
||||
tags = append(tags, flagTag{"d", dtag})
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
tags = append(tags, flagTag{"h", htag})
|
||||
}
|
||||
|
||||
if len(tags) > 0 && filter.Tags == nil {
|
||||
@@ -486,10 +589,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
if _, ok := filter.Tags[tag.key]; !ok {
|
||||
filter.Tags[tag.key] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
|
||||
}
|
||||
|
||||
if c.IsSet("since") {
|
||||
@@ -510,11 +613,25 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
|
||||
type PrintingQuerierPublisher struct {
|
||||
nostr.QuerierPublisher
|
||||
jq jqProcessor
|
||||
}
|
||||
|
||||
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
|
||||
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
|
||||
stdout(evt)
|
||||
var out string
|
||||
if p.jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := p.jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
return nil
|
||||
}
|
||||
out = v
|
||||
}
|
||||
stdout(out)
|
||||
return nil
|
||||
} else if err == eventstore.ErrDupEvent {
|
||||
return nil
|
||||
|
||||
161
serve.go
161
serve.go
@@ -6,9 +6,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -61,6 +64,14 @@ var serve = &cli.Command{
|
||||
Name: "blossom",
|
||||
Usage: "enable blossom server",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "require AUTH for all operations",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "eager-auth",
|
||||
Usage: "send AUTH challenge immediately on connect",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
db := &slicestore.SliceStore{}
|
||||
@@ -105,13 +116,72 @@ var serve = &cli.Command{
|
||||
rl.Negentropy = true
|
||||
}
|
||||
|
||||
started := make(chan bool)
|
||||
exited := make(chan error)
|
||||
|
||||
hostname := c.String("hostname")
|
||||
port := int(c.Uint("port"))
|
||||
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
if c.Bool("eager-auth") {
|
||||
khatru.RequestAuth(ctx)
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
totalConnections.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
rl.OnAuth = func(ctx context.Context, pubkey nostr.PubKey) {
|
||||
log(" got %s %s\n", color.GreenString("authenticated"), pubkey.Hex())
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
var printStatus func()
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
if err != nil {
|
||||
log("failed to count: %s\n", err)
|
||||
}
|
||||
subs := rl.GetListeningFilters()
|
||||
|
||||
blossomMsg := ""
|
||||
if c.Bool("blossom") {
|
||||
blobsStored := blobStore.Size()
|
||||
blossomMsg = fmt.Sprintf("blobs: %s, ",
|
||||
color.HiMagentaString("%d", blobsStored),
|
||||
)
|
||||
}
|
||||
|
||||
graspMsg := ""
|
||||
if c.Bool("grasp") {
|
||||
gitAnnounced := 0
|
||||
gitStored := 0
|
||||
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
|
||||
gitAnnounced++
|
||||
identifier := evt.Tags.GetD()
|
||||
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
|
||||
gitStored++
|
||||
}
|
||||
}
|
||||
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
|
||||
color.HiMagentaString("%d", gitAnnounced),
|
||||
color.HiMagentaString("%d", gitStored),
|
||||
)
|
||||
}
|
||||
|
||||
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
|
||||
color.HiMagentaString("•"),
|
||||
color.HiMagentaString("%d", totalEvents),
|
||||
blossomMsg,
|
||||
graspMsg,
|
||||
color.HiMagentaString("%d", totalConnections.Load()),
|
||||
color.HiMagentaString("%d", len(subs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if c.Bool("blossom") {
|
||||
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
|
||||
@@ -162,90 +232,63 @@ var serve = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := rl.Start(hostname, port, started)
|
||||
exited <- err
|
||||
exited <- http.Serve(ln, rl)
|
||||
}()
|
||||
|
||||
// relay logging
|
||||
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: subscribe"
|
||||
}
|
||||
}
|
||||
|
||||
negentropy := ""
|
||||
if khatru.IsNegentropySession(ctx) {
|
||||
negentropy = color.HiBlueString("negentropy ")
|
||||
}
|
||||
|
||||
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
|
||||
authedString := ""
|
||||
if pubkey, ok := khatru.GetAuthed(ctx); ok {
|
||||
authedString = fmt.Sprintf(" from %s", color.GreenString(pubkey.Hex()))
|
||||
}
|
||||
|
||||
log(" got %s%s %v%s\n",
|
||||
negentropy, color.HiYellowString("request"), colors.italic(filter), authedString)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: count"
|
||||
}
|
||||
}
|
||||
|
||||
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
|
||||
if c.Bool("auth") {
|
||||
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
|
||||
return true, "auth-required: event"
|
||||
}
|
||||
}
|
||||
|
||||
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
totalConnections.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
if err != nil {
|
||||
log("failed to count: %s\n", err)
|
||||
}
|
||||
subs := rl.GetListeningFilters()
|
||||
|
||||
blossomMsg := ""
|
||||
if c.Bool("blossom") {
|
||||
blobsStored := blobStore.Size()
|
||||
blossomMsg = fmt.Sprintf("blobs: %s, ",
|
||||
color.HiMagentaString("%d", blobsStored),
|
||||
)
|
||||
}
|
||||
|
||||
graspMsg := ""
|
||||
if c.Bool("grasp") {
|
||||
gitAnnounced := 0
|
||||
gitStored := 0
|
||||
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
|
||||
gitAnnounced++
|
||||
identifier := evt.Tags.GetD()
|
||||
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
|
||||
gitStored++
|
||||
}
|
||||
}
|
||||
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
|
||||
color.HiMagentaString("%d", gitAnnounced),
|
||||
color.HiMagentaString("%d", gitStored),
|
||||
)
|
||||
}
|
||||
|
||||
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
|
||||
color.HiMagentaString("•"),
|
||||
color.HiMagentaString("%d", totalEvents),
|
||||
blossomMsg,
|
||||
graspMsg,
|
||||
color.HiMagentaString("%d", totalConnections.Load()),
|
||||
color.HiMagentaString("%d", len(subs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
<-started
|
||||
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
|
||||
if c.Bool("grasp") {
|
||||
log(" (grasp repos at %s)", repoDir)
|
||||
|
||||
27
spell.go
27
spell.go
@@ -23,7 +23,7 @@ var spell = &cli.Command{
|
||||
Usage: "downloads a spell event and executes its REQ request",
|
||||
ArgsUsage: "[nevent_code]",
|
||||
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.StringFlag{
|
||||
Name: "pub",
|
||||
Usage: "public key to run spells in the context of (if you don't want to pass a --sec)",
|
||||
@@ -82,19 +82,13 @@ var spell = &cli.Command{
|
||||
|
||||
displayName := entry.Name
|
||||
if displayName == "" {
|
||||
displayName = entry.Content
|
||||
if len(displayName) > 28 {
|
||||
displayName = displayName[:27] + "…"
|
||||
}
|
||||
displayName = clampWithEllipsis(entry.Content, 28)
|
||||
}
|
||||
if displayName != "" {
|
||||
displayName = color.HiMagentaString(displayName) + ": "
|
||||
}
|
||||
|
||||
desc := entry.Content
|
||||
if len(desc) > 50 {
|
||||
desc = desc[0:49] + "…"
|
||||
}
|
||||
desc := clampWithEllipsis(entry.Content, 50)
|
||||
|
||||
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
|
||||
stdout(fmt.Sprintf(" %s %s%s - %s",
|
||||
@@ -254,7 +248,9 @@ func runSpell(
|
||||
|
||||
// execute
|
||||
logSpellDetails(spell)
|
||||
performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell")
|
||||
if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -448,20 +444,13 @@ func logSpellDetails(spell nostr.Event) {
|
||||
nameTag := spell.Tags.Find("name")
|
||||
name := ""
|
||||
if nameTag != nil {
|
||||
name = nameTag[1]
|
||||
if len(name) > 28 {
|
||||
name = name[:27] + "…"
|
||||
}
|
||||
name = clampWithEllipsis(nameTag[1], 28)
|
||||
}
|
||||
if name != "" {
|
||||
name = ": " + color.HiMagentaString(name)
|
||||
}
|
||||
|
||||
desc := spell.Content
|
||||
if len(desc) > 50 {
|
||||
desc = desc[0:49] + "…"
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(spell.Content, 50)
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
|
||||
|
||||
46
sync.go
46
sync.go
@@ -124,9 +124,27 @@ func getBoundKey(b negentropy.Bound) boundKey {
|
||||
type RelayThirdPartyRemote struct {
|
||||
relay *nostr.Relay
|
||||
messages chan string
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
|
||||
// fail records the error and closes the messages channel so a Receive()
|
||||
// that is already blocked on it wakes up instead of hanging forever.
|
||||
func (rtpr *RelayThirdPartyRemote) fail(err error) {
|
||||
rtpr.mu.Lock()
|
||||
if rtpr.err == nil {
|
||||
rtpr.err = err
|
||||
close(rtpr.messages)
|
||||
}
|
||||
rtpr.mu.Unlock()
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) getErr() error {
|
||||
rtpr.mu.Lock()
|
||||
defer rtpr.mu.Unlock()
|
||||
return rtpr.err
|
||||
}
|
||||
|
||||
func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdPartyRemote, error) {
|
||||
rtpr := &RelayThirdPartyRemote{
|
||||
messages: make(chan string, 3),
|
||||
@@ -141,13 +159,18 @@ func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdParty
|
||||
}
|
||||
switch env := envelope.(type) {
|
||||
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
|
||||
rtpr.err = fmt.Errorf("unexpected %s received from relay", env.Label())
|
||||
rtpr.fail(fmt.Errorf("unexpected %s received from relay", env.Label()))
|
||||
return
|
||||
case *nip77.ErrorEnvelope:
|
||||
rtpr.err = fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason)
|
||||
rtpr.fail(fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason))
|
||||
return
|
||||
case *nip77.MessageEnvelope:
|
||||
rtpr.messages <- env.Message
|
||||
rtpr.mu.Lock()
|
||||
failed := rtpr.err != nil
|
||||
rtpr.mu.Unlock()
|
||||
if !failed {
|
||||
rtpr.messages <- env.Message
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -185,12 +208,13 @@ func (rtpr *RelayThirdPartyRemote) SendClose() error {
|
||||
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
|
||||
if rtpr.err != nil {
|
||||
return "", rtpr.err
|
||||
}
|
||||
if msg, ok := <-rtpr.messages; ok {
|
||||
return msg, nil
|
||||
}
|
||||
// channel closed by fail()
|
||||
if err := rtpr.getErr(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", thirdPartyRemoteEndOfMessages
|
||||
}
|
||||
|
||||
@@ -204,6 +228,9 @@ func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.F
|
||||
}
|
||||
|
||||
func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
|
||||
// always close Deltas so the consumer goroutine doesn't hang when we return an error
|
||||
defer close(n.Deltas)
|
||||
|
||||
peerAIds := make(map[nostr.ID]struct{})
|
||||
peerBIds := make(map[nostr.ID]struct{})
|
||||
peerASkippedBounds := make(map[boundKey]struct{})
|
||||
@@ -341,7 +368,6 @@ func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
|
||||
|
||||
n.PeerA.SendClose()
|
||||
n.PeerB.SendClose()
|
||||
close(n.Deltas)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -409,7 +435,7 @@ func parseMessageBuildNext(
|
||||
skipCallback(getBoundKey(bound))
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.SkipMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.SkipMode))
|
||||
}
|
||||
|
||||
case negentropy.FingerprintMode:
|
||||
@@ -420,7 +446,7 @@ func parseMessageBuildNext(
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
|
||||
}
|
||||
case negentropy.IdListMode:
|
||||
@@ -452,7 +478,7 @@ func parseMessageBuildNext(
|
||||
fingerprint := acc.GetFingerprint(numIds)
|
||||
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(fingerprint[:])
|
||||
}
|
||||
default:
|
||||
|
||||
321
thread_helpers.go
Normal file
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
|
||||
},
|
||||
}
|
||||
28
verify.go
28
verify.go
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,8 @@ it outputs nothing if the verification is successful.`,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
evt := nostr.Event{}
|
||||
if stdinEvent == "" {
|
||||
if stdinEvent == "{}" && !isPiped() {
|
||||
// blank sentinel from getJsonsOrBlank(), use the argument instead
|
||||
stdinEvent = c.Args().First()
|
||||
if stdinEvent == "" {
|
||||
continue
|
||||
@@ -27,23 +29,35 @@ it outputs nothing if the verification is successful.`,
|
||||
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||
logverbose("<>: invalid event.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.RedString("<>: invalid event."))
|
||||
continue
|
||||
}
|
||||
|
||||
if evt.GetID() != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
|
||||
logverbose("%s: invalid id.\n", evt.ID.Hex())
|
||||
impliedID := evt.GetID()
|
||||
idsMatch := impliedID == evt.ID
|
||||
logverbose(
|
||||
"%s\n%s %s\n%s %s\n%s %s\n%s %s\n%s %s\n",
|
||||
color.CyanString("verifying event:"),
|
||||
color.BlueString(" event: "), stdinEvent,
|
||||
color.BlueString(" given id: "), color.YellowString("%s", evt.ID),
|
||||
color.BlueString(" serialized:"), string(evt.Serialize()),
|
||||
color.BlueString(" implied id:"), color.YellowString("%s", impliedID),
|
||||
color.BlueString(" ids match: "), color.New(map[bool]color.Attribute{true: color.FgGreen, false: color.FgRed}[idsMatch]).Sprint(idsMatch),
|
||||
)
|
||||
|
||||
if impliedID != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", impliedID, evt.ID)
|
||||
logverbose("%s\n", color.RedString("invalid id: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
if !evt.VerifySignature() {
|
||||
ctx = lineProcessingError(ctx, "invalid signature")
|
||||
logverbose("%s: invalid signature.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
logverbose("%s: valid.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
47
wallet.go
47
wallet.go
@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
|
||||
relays := sys.FetchOutboxRelays(ctx, pk, 3)
|
||||
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
|
||||
if w == nil {
|
||||
return nil, nil, fmt.Errorf("error loading walle")
|
||||
return nil, nil, fmt.Errorf("error loading wallet")
|
||||
}
|
||||
|
||||
w.Processed = func(evt nostr.Event, err error) {
|
||||
@@ -105,7 +105,7 @@ var wallet = &cli.Command{
|
||||
Usage: "displays the current wallet balance",
|
||||
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
Flags: combineFlags([][]cli.Flag{},
|
||||
&cli.BoolFlag{
|
||||
Name: "stream",
|
||||
Usage: "keep listening for wallet-related events and logging them",
|
||||
@@ -139,7 +139,11 @@ var wallet = &cli.Command{
|
||||
}
|
||||
|
||||
for _, url := range w.Mints {
|
||||
stdout(strings.Split(url, "://")[1])
|
||||
if _, host, ok := strings.Cut(url, "://"); ok {
|
||||
stdout(host)
|
||||
} else {
|
||||
stdout(url)
|
||||
}
|
||||
}
|
||||
|
||||
closew()
|
||||
@@ -195,7 +199,11 @@ var wallet = &cli.Command{
|
||||
}
|
||||
|
||||
for _, token := range w.Tokens {
|
||||
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
|
||||
_, mintHost, _ := strings.Cut(token.Mint, "://")
|
||||
if mintHost == "" {
|
||||
mintHost = token.Mint
|
||||
}
|
||||
stdout(token.ID(), token.Proofs.Amount(), mintHost)
|
||||
}
|
||||
|
||||
closew()
|
||||
@@ -221,7 +229,11 @@ var wallet = &cli.Command{
|
||||
for _, token := range w.Tokens {
|
||||
if slices.Contains(ids, token.ID()) {
|
||||
w.DropToken(ctx, token.ID())
|
||||
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
|
||||
_, mintHost, _ := strings.Cut(token.Mint, "://")
|
||||
if mintHost == "" {
|
||||
mintHost = token.Mint
|
||||
}
|
||||
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +370,10 @@ var wallet = &cli.Command{
|
||||
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Usage: "npub, nprofile, nevent or hex pubkey",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mint",
|
||||
Usage: "send from a specific mint",
|
||||
@@ -368,9 +384,8 @@ var wallet = &cli.Command{
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
|
||||
if c.Args().Len() < 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
@@ -484,8 +499,13 @@ var wallet = &cli.Command{
|
||||
} else {
|
||||
return fmt.Errorf("missing --private-key")
|
||||
}
|
||||
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
|
||||
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
|
||||
} else if sk := c.String("private-key"); sk != "" {
|
||||
if !c.Bool("force") {
|
||||
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
|
||||
}
|
||||
if err := w.SetPrivateKey(ctx, sk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
kr, _, _ := gatherKeyerFromArguments(ctx, c)
|
||||
@@ -502,13 +522,18 @@ var wallet = &cli.Command{
|
||||
info.ParseEvent(ie.Event)
|
||||
}
|
||||
|
||||
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
|
||||
if mints := c.StringSlice("mint"); len(mints) > 0 {
|
||||
info.Mints = mints
|
||||
} else if len(info.Mints) == 0 {
|
||||
info.Mints = w.Mints
|
||||
}
|
||||
if len(info.Mints) == 0 {
|
||||
return fmt.Errorf("missing --mint")
|
||||
}
|
||||
|
||||
// the pubkey tag is mandatory, without it no one can send us nutzaps
|
||||
info.PublicKey = nostr.PubKey(w.PublicKey.SerializeCompressed()[1:])
|
||||
|
||||
evt := nostr.Event{}
|
||||
if err := info.ToEvent(ctx, kr, &evt); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user