Compare commits

...

134 Commits

Author SHA1 Message Date
Yasuhiro Matsumoto
9df857a076 group chat: handle events synchronously in select, drop goroutine and mutex 2026-06-04 21:41:11 +09:00
alltheseas
52cf03d423 gift unwrap: override rumor pubkey from seal and recompute ID
NIP-17 requires clients verify that the kind:14 pubkey matches
the kind:13 pubkey to prevent sender impersonation. The CLI
gift unwrap path outputs the rumor as-is without setting the
pubkey from the verified seal or recomputing the event ID.

This mirrors nip59.GiftUnwrap() which does both operations.
The override is placed after the ZeroID sentinel check to
preserve decryption failure detection.

Fixes https://github.com/fiatjaf/nak/issues/110

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 09:16:03 -03:00
alltheseas
d0c291b25c gift unwrap: verify seal signature before trusting pubkey
The CLI gift unwrap path skips seal signature verification that
nip59.GiftUnwrap() performs. Without this check, a forged seal
with an arbitrary pubkey would be trusted for decryption key
lookup and sender identity.

Fixes https://github.com/fiatjaf/nak/issues/110

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 09:16:03 -03:00
mattn
6ee60cb197 Merge pull request #139 from mattn/fix/count-auth-handler-consistency
count: use Pool.AuthRequiredHandler to match other commands
2026-06-04 01:56:59 +00:00
mattn
c2eb2d7bdd Merge pull request #138 from mattn/fix/req-sync-save-error
req: propagate non-duplicate save errors when loading sync file
2026-06-04 01:56:34 +00:00
Yasuhiro Matsumoto
168a182d50 count: use Pool.AuthRequiredHandler to match other commands. 2026-06-04 10:55:14 +09:00
Yasuhiro Matsumoto
f202f038a4 req: propagate non-duplicate save errors when loading sync file. 2026-06-04 10:52:46 +09:00
mattn
1043b2a7af Merge pull request #137 from mattn/fix/req-tag-decode-dead-code
req: actually decode bech32 values for single-letter --tag flags
2026-06-04 01:51:56 +00:00
Yasuhiro Matsumoto
9c3b4b69b6 req: actually decode bech32 values for single-letter --tag flags. 2026-06-04 10:50:53 +09:00
mattn
1c542deb5e Merge pull request #136 from mattn/fix/group-chat-race
group: fix data race and out-of-bounds panic in chat command
2026-06-04 01:50:08 +00:00
Yasuhiro Matsumoto
cefc0b91d0 group: fix data race and out-of-bounds panic in chat command. 2026-06-04 09:49:48 +09:00
mattn
8c97c7becb Merge pull request #135 from mattn/refactor/lmdb-early-return
lmdb: invert configPath check to flatten setupLocalDatabases
2026-06-04 00:49:14 +00:00
mattn
ada938c3a8 Merge pull request #134 from mattn/refactor/blossom-mirror-early-return
blossom: drop unnecessary else after early return in mirror command
2026-06-04 00:48:35 +00:00
mattn
c2d74b846e Merge pull request #133 from mattn/refactor/git-init-flatten-nesting
git: flatten nested ifs in ngit remote migration with guard clauses
2026-06-04 00:48:07 +00:00
Yasuhiro Matsumoto
06bd1ddb11 lmdb: invert configPath check to flatten setupLocalDatabases. 2026-06-04 09:48:03 +09:00
Yasuhiro Matsumoto
ebd93964de blossom: drop unnecessary else after early return in mirror command. 2026-06-04 09:21:52 +09:00
Yasuhiro Matsumoto
a285ac6723 git: flatten nested ifs in ngit remote migration with guard clauses. 2026-06-04 09:20:14 +09:00
fiatjaf
e2f791ab85 fetch: support --auth. 2026-06-02 21:35:58 -03:00
mattn
ed5af14305 Merge pull request #130 from fiatjaf/go-tty-v2
go-tty: bump to v2.
2026-05-29 15:30:27 +00:00
Yasuhiro Matsumoto
e5f4b07c3d go-tty: bump to v2. 2026-05-30 00:29:51 +09:00
fiatjaf
b41260f663 --outbox help text better. 2026-05-26 19:51:18 -03:00
fiatjaf
c9e1511865 bunker: fix relay handling, don't mix custom with main relay URLs. 2026-05-21 20:25:07 -03:00
fiatjaf
5af1d111ba key-combine: support parsing npubs and nprofiles. 2026-05-20 16:14:11 -03:00
fiatjaf
2922a0de25 git: support nostr://... URLs without requiring the <relay> component. 2026-05-17 17:10:29 -03:00
fiatjaf
35d628ef6d bunker: fix concurrent map writes (url.Values is a map). 2026-05-16 23:16:30 -03:00
fiatjaf
a2f109c8c8 encode: support reading pointer data as json from stdin also in specific subcommands. 2026-05-16 19:32:15 -03:00
fiatjaf
d9cc9f1d80 encode: fix reading pointer objects as json from stdin. 2026-05-16 14:43:38 -03:00
fiatjaf
d97937eaa4 encode: correctly fail on unsupported modes. 2026-05-16 14:19:42 -03:00
fiatjaf
bdd3aa1093 bunker: fix waiting for slow relays? 2026-05-15 17:27:18 -03:00
fiatjaf
02c12cb671 decode: fix missing identifiers in printed json. 2026-05-08 12:53:42 -03:00
fiatjaf
c04add227d fix serve never starting. 2026-05-05 11:54:09 -03:00
mattn
c3f1ef7877 Merge pull request #126 from mattn/fix/blossom-hash-decode
blossom: fix wrong source length in hex decode of download hash
2026-05-05 21:41:38 +09:00
mattn
d0e719fb93 Merge pull request #128 from mattn/fix/nsite-path-traversal
nsite: fix path traversal vulnerability in download command
2026-05-05 21:39:55 +09:00
Yasuhiro Matsumoto
187842214e nsite: fix path traversal vulnerability in download command. 2026-05-05 21:36:46 +09:00
mattn
405be6efa9 Merge pull request #127 from mattn/fix/publish-reply-nil-panic
publish: avoid nil panic when --reply decodes to an unexpected type
2026-05-05 21:18:10 +09:00
Yasuhiro Matsumoto
47e462fe55 publish: avoid nil panic when --reply decodes to an unexpected type. 2026-05-05 21:15:00 +09:00
Yasuhiro Matsumoto
99ef7a8c91 blossom: fix wrong source length in hex decode of download hash. 2026-05-05 21:08:55 +09:00
fiatjaf
a152d7f633 verify: debug mode (-vv) prints useful info about event serialization. 2026-04-23 22:26:52 -03:00
fiatjaf
25d2045640 event: --no-verify flag. 2026-04-23 20:54:58 -03:00
fiatjaf
09b9939956 nsite command. 2026-04-23 20:50:03 -03:00
fiatjaf
2a8cd898c2 allow --auth on 'group'. 2026-04-19 20:37:41 -03:00
fiatjaf
eae828e03b update to the fact that Pool doesn't use PoolOptions anymore, just modify the global pool directly. 2026-04-19 20:17:00 -03:00
fiatjaf
9ae5798a24 print event in 'validate' and 'filter', apply jq on 'filter' and 'req' with gojq. 2026-04-19 19:28:34 -03:00
fiatjaf
5930437578 nak validate 2026-04-18 19:15:51 -03:00
fiatjaf
2193491c3b encode: support naddr for replaceable events and improve kind and d-tag validation..
fixes https://github.com/fiatjaf/nak/issues/122

honestly I don't know why you want to naddr-encode replaceable events, that sounds like an awful idea, but what can I do.
2026-04-18 15:07:27 -03:00
fiatjaf
d3f4548dbd why were we recreating the pool? 2026-04-18 11:13:08 -03:00
fiatjaf
07b791e90a adapt to some nostrlib changes. 2026-04-18 09:19:43 -03:00
fiatjaf
2c3d72b337 nak key validate. 2026-04-10 09:31:51 -03:00
Ren Amamiya
46c68462db git.shakespeare.diy not git.shakespeare.dyi 2026-04-09 11:49:34 -03:00
fiatjaf
0834420d89 replace %w with %s in lineProcessingError(). 2026-04-05 18:24:59 -03:00
fiatjaf
217352ed0b blossom: take multiple --server flags. 2026-04-05 17:12:01 -03:00
fiatjaf
318838b3ff key: a command for expanding partial keys by left-padding with zeroes. 2026-04-03 01:36:08 -03:00
fiatjaf
b811396d8b fetch: exit with error if event is not fetched. 2026-04-02 03:59:45 -03:00
fiatjaf
2bc1cf3417 performReq now uses "read" relays from p/P-tags when no authors are specified in outbox mode. 2026-04-02 03:46:54 -03:00
fiatjaf
65ddf7b821 use appendUnique from nostrlib. 2026-04-02 03:24:05 -03:00
fiatjaf
e19306b124 event: --outbox flag for publishing. 2026-04-02 03:14:19 -03:00
fiatjaf
e059fba487 event/req: accept -h tag and addresses in -a tag. 2026-03-30 12:20:52 -03:00
fiatjaf
00f44958de git: take npub, nip05 or hex for user fields on "init". 2026-03-30 10:03:35 -03:00
fiatjaf
e37ea23a8d groups: stop appending undefined tags to edit-metadata. 2026-03-30 08:56:24 -03:00
fiatjaf
3c4cb533b4 groups: create-group. 2026-03-30 08:56:24 -03:00
fiatjaf
be26dfca3c print "connecting to..." message without the leading scheme. 2026-03-30 08:56:24 -03:00
mattn
26518ec260 Merge pull request #119 from fiatjaf/fix/curl-args-oob
fix: prevent out-of-bounds panic in curl flag parsing
2026-03-30 15:09:52 +09:00
mattn
de599c64f6 Merge pull request #118 from fiatjaf/fix/event-split-safety
fix: use strings.Cut instead of strings.Split to avoid potential panic
2026-03-30 14:10:25 +09:00
Yasuhiro Matsumoto
3d650ee502 fix: use strings.Cut instead of strings.Split to avoid potential panic
strings.Split(url, "://")[1] panics if the URL has no "://" separator.
strings.Cut handles this safely by returning an empty string.
2026-03-30 13:43:29 +09:00
Yasuhiro Matsumoto
67920ccbdd fix: prevent out-of-bounds panic in curl flag parsing
When a flag expecting a value (e.g. --sec) is the last argument,
the code incremented the index without checking if the next
argument exists, causing a potential panic on os.Args access.
2026-03-30 13:43:08 +09:00
fiatjaf
0dbe14aa93 git: 'download' command for downloading individual files. 2026-03-26 17:46:47 -03:00
fiatjaf
ec1721cfe3 count: support CLOSED and AUTH.
fixes https://github.com/fiatjaf/nak/issues/117
2026-03-25 21:42:11 -03:00
fiatjaf
3b318fec54 event: must clear events before reusing. 2026-03-24 22:34:10 -03:00
fiatjaf
82275e4b9e when parsing a nip05 as a pubkey there is no point in falling back to parsing as hex or npub. 2026-03-22 17:17:28 -03:00
fiatjaf
ae98e99ec6 update nostrlib to fix stuck subscriptions. 2026-03-20 20:56:38 -03:00
Yasuhiro Matsumoto
29db04b7ea fix: add missing target flag to nutzap command 2026-03-20 13:44:14 -03:00
fiatjaf
59fecacbdc ensure we're adding a "K" tag to kind:1111 events. 2026-03-17 20:48:53 -03:00
fiatjaf
faac4d9440 ensure an event is not resigned if it was already signed and wasn't changed. 2026-03-17 19:32:47 -03:00
fiatjaf
f00a4a7d2a fix potential double-yield() usage on reading lines from stdin. 2026-03-17 14:57:42 -03:00
fiatjaf
d823ac55a6 fix nostrlib dependency. 2026-03-17 14:57:11 -03:00
fiatjaf
105ccef7ec gift: fix potential keys ordering and rumor decryption failure check. 2026-03-17 14:56:55 -03:00
fiatjaf
9d4df21836 gift/encrypt: fix wording and yield returns. 2026-03-17 11:49:09 -03:00
fiatjaf
952d638ac3 git: fix --owner flag in "init".
fixes https://github.com/fiatjaf/nak/issues/113
2026-03-16 16:09:00 -03:00
fiatjaf
c1e72e0af1 convert tags to appropriate hex pubkeys, ids etc, even from nip05, but check the tag name first for better (hardcoded) context. 2026-03-13 00:30:46 -03:00
fiatjaf
5f4efdbc69 'group forum' with the ui like 'nak git issue'. 2026-03-12 19:03:20 -03:00
fiatjaf
4967db13a1 update nostrlib dependency. 2026-03-12 16:50:19 -03:00
fiatjaf
da0b753371 group: edit-metadata as PUT, not PATCH. 2026-03-12 16:33:50 -03:00
fiatjaf
bef67d35d2 groups: replace no-text with supported_kinds support. 2026-03-12 16:20:23 -03:00
fiatjaf
a8fb2e4189 git: allow closing a patch as applied. 2026-03-12 16:19:58 -03:00
fiatjaf
7aea4cf9a1 git: ensure some operations are only completed if you're a maintainer. 2026-03-08 21:24:28 -03:00
fiatjaf
d472efe707 git: issue and patch closing. 2026-03-08 21:06:34 -03:00
fiatjaf
17341b3af6 git: issue and patch listing unified in base command. 2026-03-08 21:02:52 -03:00
fiatjaf
ff5a7b4ba7 git: issue replying in the middle of a thread. 2026-03-08 18:29:53 -03:00
mattn
7596e317b8 Merge pull request #109 from fiatjaf/fix/group-forum-tag-filter
fix: remove incorrect '#' prefix from TagMap key in group forum filter
2026-03-08 11:24:24 +09:00
mattn
da7fc3fac3 Merge pull request #108 from mattn/fix/bunker-format-verbs
fix: use %s instead of %w in log/color.RedString format strings
2026-03-08 11:22:48 +09:00
mattn
0e8475b388 Merge pull request #107 from mattn/fix/wallet-typo
fix typo
2026-03-08 11:19:04 +09:00
Yasuhiro Matsumoto
5532c884bc fix: use %s instead of %w in log/color.RedString format strings
%w is only valid in fmt.Errorf. When used in fmt.Sprintf (via
color.RedString) or log, it produces garbled output like
"%!w(MISSING)" and the actual error is not displayed.
2026-03-08 11:15:14 +09:00
Yasuhiro Matsumoto
05857ab190 fix: typo in wallet error message ("walle" -> "wallet") 2026-03-08 11:14:04 +09:00
Yasuhiro Matsumoto
bf22a63404 fix: remove incorrect '#' prefix from TagMap key in group forum filter
The forum subcommand used "#h" as the TagMap key, but TagMap keys
should not include the '#' prefix. All other TagMap usages in this
file correctly use just the tag letter (e.g. "d", "h").
2026-03-08 11:13:42 +09:00
fiatjaf
6bd2d1cdfc git: issues and patches improved enormously. 2026-03-07 23:10:00 -03:00
fiatjaf
9c8d59c5c5 group: talk prints URL to meet.livekit.io 2026-03-07 23:10:00 -03:00
fiatjaf
5957c08d15 git: issues and patches. 2026-03-06 18:40:54 -03:00
fiatjaf
f59c8a670d group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now). 2026-03-05 22:49:23 -03:00
fiatjaf
61a3b89d08 req: handle values from closeds better.
(it may be closed when all is ok, so we shouldn't rely on yielding an actual value)
2026-03-04 16:16:46 -03:00
fiatjaf
d61fdc4cb4 git: fix first publishing of a new repository announcement. 2026-03-04 16:01:22 -03:00
fiatjaf
0735ded0fc update nostrlib to hopefully fix issues with zombie connections. 2026-03-04 00:10:20 -03:00
fiatjaf
5233a77510 nak profile 2026-02-23 20:56:21 -03:00
Alex Gleason
fd34cc7c5e git clone: support NIP-05 names in nostr:// URIs
Resolve NIP-05 identifiers (e.g. chad@chadwick.site) in the owner
position of nostr:// URIs, so 'nak git clone nostr://user@domain/relay/id'
works without manually resolving to an npub first.
2026-02-20 23:11:36 -03:00
JannikStreek
2d151c2ac8 Fix: docker build 2026-02-19 10:58:54 -03:00
fiatjaf
037e8efcc6 count: support reading filters from stdin like req. 2026-02-12 14:34:24 -03:00
fiatjaf
1b380dea9a count: support the same flags as req.
fixes https://github.com/fiatjaf/nak/issues/103
2026-02-12 14:25:57 -03:00
fiatjaf
f126b3f7ee mcp: searching for pubkeys and nip05s returns them directly. 2026-02-06 13:52:15 -03:00
fiatjaf
dc5ffe5129 bunker: fix authorizing pubkeys from --authorized-secrets.
closes https://github.com/fiatjaf/nak/issues/102
2026-02-04 23:26:26 -03:00
Yasuhiro Matsumoto
7637b5018f avoid panic when mint URL has no '://' 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
d5ab34bb2f remove redundant if err != nil in main 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
49345333c4 check dateparser error before using date.Time 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
b5de7b78bc prevent panic on AUTH challenge tag (nil or len<2) 2026-02-01 14:24:51 -03:00
fiatjaf
ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
fiatjaf
c0bbf73961 update nostrlib to fix decoding of "note1".
fixes https://github.com/fiatjaf/nak/issues/99
2026-01-26 23:45:12 -03:00
fiatjaf
5320feee4f more installation formulas. 2026-01-26 22:46:36 -03:00
fiatjaf
58c1fab0f0 mcp: search method. 2026-01-26 22:34:33 -03:00
fiatjaf
5f30009e72 decode: print error when failed. 2026-01-26 22:34:33 -03:00
fiatjaf
548918578b add new capabilities to README. 2026-01-26 22:34:33 -03:00
Alex Gleason
7757400ab3 Add easy install script 2026-01-26 22:27:28 -03:00
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
fiatjaf
e64ad8f078 git: better printing of server statuses. 2026-01-18 21:44:06 -03:00
fiatjaf
b36718caaa git: status.
and a fix for repository announcements getting updated every time due to time shifts.
2026-01-18 21:32:01 -03:00
fiatjaf
5c658c38f1 bring back old github actions builder. 2026-01-18 14:55:03 -03:00
fiatjaf
2a5ce3b249 blossom mirror to only take a URL and do its thing, not try to list blobs. 2026-01-18 14:47:28 -03:00
fiatjaf
c0b85af734 make cgofuse the default for "fs" only on windows. on linux and mac it needs a "cgofuse" build tag. 2026-01-18 10:54:34 -03:00
Yasuhiro Matsumoto
cb2247c9da implement blossom mirror 2026-01-17 11:07:47 -03:00
53 changed files with 6877 additions and 913 deletions

View File

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

View File

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

View File

@@ -1,9 +1,19 @@
# nak, the nostr army knife
install with `go install github.com/fiatjaf/nak@latest` or
[download a binary](https://github.com/fiatjaf/nak/releases).
install with this one-liner:
or get the source with `git clone https://github.com/fiatjaf/nak` then install with `go install` or run with docker using `docker build -t nak . && docker run nak event`.
```sh
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install nak` if you use **Nix**.
## what can you do with it?
@@ -34,7 +44,7 @@ publishing to wss://relay.damus.io... success.
"Activando modo zen…\n\n#GM #Nostr #Hispano"
```
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
### decode a NIP-19 note1 code, add a relay hint, encode it back to nevent1
```shell
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
@@ -200,6 +210,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 +249,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
```
@@ -427,6 +449,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 +464,11 @@ 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>"
```

View File

@@ -3,9 +3,11 @@ package main
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"os"
"unsafe"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
@@ -19,7 +21,7 @@ var blossomCmd = &cli.Command{
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "the hostname of the target mediaserver",
@@ -41,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)
@@ -69,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 {
@@ -84,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
},
},
@@ -137,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 {
@@ -229,12 +255,55 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
Name: "mirror",
Usage: "",
Description: ``,
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,
ArgsUsage: "",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
var bd blossom.BlobDescriptor
if input := c.Args().First(); input != "" {
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
return err
}
out, _ := json.Marshal(bd)
stdout(out)
return nil
}
for input := range getJsonsOrBlank() {
if input == "{}" {
continue
}
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
continue
}
out, _ := json.Marshal(bd)
stdout(out)
}
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -246,5 +315,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
servers := c.StringSlice("server")
if err != nil {
return nil, err
}
return blossom.NewClient(servers[0], keyer), nil
}

387
bunker.go
View File

@@ -4,13 +4,15 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"maps"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -73,14 +75,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 +118,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 +138,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 +154,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,7 +176,9 @@ 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
@@ -205,17 +216,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, nil)
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")
}
@@ -232,14 +250,28 @@ var bunker = &cli.Command{
// 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())
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 +281,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 +293,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 +311,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 +322,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 +341,97 @@ 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
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// 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) {
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
@@ -357,49 +453,56 @@ var bunker = &cli.Command{
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.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)
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
return
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
return
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
}
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}()
}
@@ -410,24 +513,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 +618,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
}

220
count.go
View File

@@ -7,70 +7,61 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
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,
},
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"COUNT\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
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{})
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -84,95 +75,62 @@ 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 {
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)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
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
},
}

View File

@@ -120,8 +120,10 @@ 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)

View File

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

View File

@@ -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, nil)
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}

199
encode.go
View File

@@ -2,13 +2,66 @@ package main
import (
"context"
stdjson "encoding/json"
"fmt"
"iter"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"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 +74,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 +103,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 +137,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 +156,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 +184,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 +239,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 +281,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,
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
},
&cli.StringSliceFlag{
Name: "relay",
@@ -239,27 +308,55 @@ var encode = &cli.Command{
},
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 := nostr.Kind(c.Int("kind"))
d := c.String("identifier")
relays := c.StringSlice("relay")
kind := c.Int("kind")
if kind < 30000 || kind >= 40000 {
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
if entityPtr, ok := parseEntityPointerInput(target); ok {
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
if pubkey == nostr.ZeroPK {
pubkey = entityPtr.PublicKey
}
if kind == 0 {
kind = entityPtr.Kind
}
if !c.IsSet("identifier") {
d = entityPtr.Identifier
}
} else if target != "" {
d = target
}
if d == "" {
d = c.String("identifier")
if pubkey == nostr.ZeroPK {
ctx = lineProcessingError(ctx, "pubkey must be set")
continue
}
if kind == 0 {
ctx = lineProcessingError(ctx, "kind must be set")
continue
}
if kind.IsAddressable() {
if d == "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
continue
}
} else if kind.IsReplaceable() {
if d != "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
continue
}
} else {
ctx = lineProcessingError(ctx, "can only encode addressable events")
continue
}
relays := c.StringSlice("relay")
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
relays = appendUnique(relays, r)
relays = nostr.AppendUnique(relays, r)
}
}

View File

@@ -123,7 +123,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)
}

145
event.go
View File

@@ -66,6 +66,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",
@@ -86,6 +98,12 @@ example:
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&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,
},
&cli.UintFlag{
Name: "kind",
Aliases: []string{"k"},
@@ -94,6 +112,12 @@ example:
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 +147,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"},
@@ -139,22 +168,7 @@ example:
),
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)
}
}
relayUrls := c.Args().Slice()
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
@@ -169,6 +183,11 @@ example:
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
evt.CreatedAt = 0
clear(evt.Tags)
evt.ID = nostr.ZeroID
evt.PubKey = nostr.ZeroPK
evt.Sig = [64]byte{}
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
@@ -212,8 +231,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 +244,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 +291,9 @@ example:
}
mustRehashAndResign = true
}
if authorPubKey != nostr.ZeroPK {
evt.PubKey = authorPubKey
}
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
@@ -253,7 +303,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 +318,7 @@ example:
if err != nil {
return err
}
} else {
} else if evt.PubKey == nostr.ZeroPK {
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
@@ -279,7 +329,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,6 +360,42 @@ example:
}
}
var relays []*nostr.Relay
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 {
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays = connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
}
// print event as json
var result string
if c.Bool("envelope") {
@@ -340,7 +432,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, " ") + " ]? ") {

View File

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

View File

@@ -19,8 +19,13 @@ example:
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
`,
DisableSliceFlagSeparator: true,
Flags: reqFilterFlags,
ArgsUsage: "[event_json] [base_filter_json]",
Flags: append(append([]cli.Flag{}, reqFilterFlags...),
&cli.StringFlag{
Name: "jq",
Usage: "filter matching events with jq expression",
},
),
ArgsUsage: "[event_json] [base_filter_json]",
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
@@ -54,6 +59,11 @@ example:
return err
}
jq, err := jqPrepare(c.String("jq"))
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
@@ -83,7 +93,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, _ = json.MarshalToString(v)
}
stdout(out)
} else {
logverbose("event %s didn't match %s", evt, baseFilter)
}

View File

@@ -105,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
ts = date.Time
if err != nil {
return err
}
ts = date.Time
}
if t.timestamp != nil {
@@ -183,6 +183,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]
)

52
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows && !openbsd
//go:build !windows && !openbsd && !cgofuse
package main
@@ -13,8 +13,10 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
@@ -62,7 +64,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := NewFSRoot(
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -73,7 +75,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
FSOptions{
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
@@ -81,22 +83,21 @@ var fsCmd = &cli.Command{
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
timeout := time.Second * 120
server, err := fs.Mount(mountpoint, root, &fs.Options{
MountOptions: fuse.MountOptions{
Debug: isVerbose,
Name: "nak",
FsName: "nak",
RememberInodes: true,
},
AttrTimeout: &timeout,
EntryTimeout: &timeout,
Logger: nostr.DebugLogger,
})
if err != nil {
return fmt.Errorf("mount failed: %w", err)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
@@ -106,12 +107,17 @@ var fsCmd = &cli.Command{
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
err := server.Unmount()
if err != nil {
chErr <- fmt.Errorf("unmount failed: %w", err)
} else {
log("ok\n")
chErr <- nil
}
}()
// wait for signals
// serve the filesystem until unmounted
server.Wait()
return <-chErr
},
}

118
fs_cgo.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build cgofuse && !windows && !openbsd
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
},
&cli.DurationFlag{
Name: "auto-publish-notes",
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
Value: time.Second * 30,
},
&cli.DurationFlag{
Name: "auto-publish-articles",
Usage: "delay after which edited articles will be auto-published.",
Value: time.Hour * 24 * 365 * 2,
DefaultText: "basically infinite",
},
),
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
mountpoint := c.Args().First()
if mountpoint == "" {
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
}
var kr nostr.User
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
kr = signer
} else {
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
}
apnt := c.Duration("auto-publish-notes")
if apnt < 0 {
apnt = time.Hour * 24 * 365 * 3
}
apat := c.Duration("auto-publish-articles")
if apat < 0 {
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
ch := make(chan os.Signal, 1)
chErr := make(chan error)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
}()
// wait for signals
return <-chErr
},
}

View File

@@ -12,6 +12,7 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
@@ -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,
},

29
gift.go
View File

@@ -179,7 +179,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 +209,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 +230,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 +239,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 +254,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 +278,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())
}
@@ -321,7 +335,6 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
return eSec, true, nil
}
}

1443
git.go

File diff suppressed because it is too large Load Diff

19
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86
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,13 @@ require (
golang.org/x/term v0.32.0
)
require (
fiatjaf.com/lib v0.3.7
github.com/hanwen/go-fuse/v2 v2.9.0
github.com/itchyny/gojq v0.12.19
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 +55,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 +72,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 +80,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 +106,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

32
go.sum
View File

@@ -1,5 +1,7 @@
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86 h1:p3HnX1UDT/CfiMvTc4yTcxHQm08ri7DM32P1uKkFNKg=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86/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=
@@ -142,11 +148,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,6 +177,8 @@ 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -184,16 +198,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=
@@ -314,8 +330,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=

1102
group.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,16 @@ import (
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"unicode"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
@@ -26,7 +27,7 @@ import (
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"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"
)
@@ -61,6 +62,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 +77,7 @@ func getJsonsOrBlank() iter.Seq[string] {
finalJsonErr = nil
if !yield(stdinEvent) {
stopped = true
return false
}
@@ -82,8 +85,14 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if stopped {
return
}
if !hasStdin {
yield("{}")
if !yield("{}") {
return
}
}
if finalJsonErr != nil {
@@ -94,15 +103,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
}
}
}
}
@@ -169,23 +186,17 @@ func connectToAllRelays(
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() {
@@ -230,7 +241,7 @@ func connectToAllRelays(
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
@@ -261,7 +272,7 @@ func connectToSingleRelay(
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
@@ -415,19 +426,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 +470,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)
@@ -526,8 +523,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,22 +547,89 @@ 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 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,

View File

@@ -13,7 +13,7 @@ import (
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/mattn/go-tty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
)

73
install.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env sh
set -e
# detect OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux";;
Darwin*) echo "darwin";;
FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";;
*)
echo "error: unsupported OS $(uname -s)" >&2
exit 1
;;
esac
}
# detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";;
*)
echo "error: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
}
# set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# check if curl is available
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}"
TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# create install directory and download
mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH"
echo "installed nak $TAG to $TARGET_PATH"
# check if install dir is in PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "note: add $INSTALL_DIR to your PATH" ;;
esac
else
echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1
fi

83
jq.go Normal file
View File

@@ -0,0 +1,83 @@
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;
`
type jqProcessor func(nostr.Event) (any, bool, error)
func jqPrepare(expr string) (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) (any, bool, error) {
input, err := toJQInput(evt)
if err != nil {
return nil, false, err
}
iter := code.Run(input)
for {
v, ok := iter.Next()
if !ok {
return v, false, nil
}
if err, ok := v.(error); ok {
return v, false, err
}
if jqTruthy(v) {
return v, true, nil
}
}
}, nil
}
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
}
}

103
key.go
View File

@@ -23,9 +23,11 @@ var key = &cli.Command{
Commands: []*cli.Command{
generate,
public,
expand,
encryptKey,
decryptKey,
combine,
validate,
},
}
@@ -67,6 +69,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",
@@ -155,6 +188,53 @@ 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.([32]byte)
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 combine = &cli.Command{
Name: "combine",
Usage: "combines two or more pubkeys using musig2",
@@ -183,15 +263,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 +277,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,6 +295,8 @@ 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
}
}

44
lmdb.go
View File

@@ -16,28 +16,30 @@ 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)
_, err := lmdbh.NewLMDBHints(hintsPath)
if err != nil {
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
}
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
}
}

30
main.go
View File

@@ -49,10 +49,14 @@ var app = &cli.Command{
curl,
fsCmd,
publish,
nsite,
git,
group,
nip,
syncCmd,
spell,
profile,
validateCmd,
},
Version: version,
Flags: []cli.Flag{
@@ -101,13 +105,11 @@ var app = &cli.Command{
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"}},
}
return ctx, nil
},
@@ -118,6 +120,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 +134,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 +142,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)
}

149
mcp.go
View File

@@ -6,6 +6,7 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
@@ -33,10 +34,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 +106,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 +137,67 @@ 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)
}
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 +209,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()),
@@ -238,6 +250,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)
},
}

56
nostrfs/asyncfile.go Normal file
View File

@@ -0,0 +1,56 @@
package nostrfs
import (
"context"
"sync/atomic"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"fiatjaf.com/nostr"
)
type AsyncFile struct {
fs.Inode
ctx context.Context
fetched atomic.Bool
data []byte
ts nostr.Timestamp
load func() ([]byte, nostr.Timestamp)
}
var (
_ = (fs.NodeOpener)((*AsyncFile)(nil))
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
)
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
out.Size = uint64(len(af.data))
out.Mtime = uint64(af.ts)
return fs.OK
}
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
return nil, fuse.FOPEN_KEEP_CACHE, 0
}
func (af *AsyncFile) Read(
ctx context.Context,
f fs.FileHandle,
dest []byte,
off int64,
) (fuse.ReadResult, syscall.Errno) {
end := int(off) + len(dest)
if end > len(af.data) {
end = len(af.data)
}
return fuse.ReadResultData(af.data[off:end]), 0
}

View File

@@ -0,0 +1,50 @@
package nostrfs
import (
"context"
"syscall"
"unsafe"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type DeterministicFile struct {
fs.Inode
get func() (ctime, mtime uint64, data string)
}
var (
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
_ = (fs.NodeReader)((*DeterministicFile)(nil))
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
)
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
return &DeterministicFile{
get: get,
}
}
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
var content string
out.Mode = 0444
out.Ctime, out.Mtime, content = f.get()
out.Size = uint64(len(content))
return fs.OK
}
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
_, _, content := f.get()
data := unsafe.Slice(unsafe.StringData(content), len(content))
end := int(off) + len(dest)
if end > len(data) {
end = len(data)
}
return fuse.ReadResultData(data[off:end]), fs.OK
}

408
nostrfs/entitydir.go Normal file
View File

@@ -0,0 +1,408 @@
package nostrfs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type EntityDir struct {
fs.Inode
root *NostrRoot
publisher *debouncer.Debouncer
event *nostr.Event
updating struct {
title string
content string
publishedAt uint64
}
}
var (
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
_ = (fs.NodeCreater)((*EntityDir)(nil))
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
)
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Ctime = uint64(e.event.CreatedAt)
if e.updating.publishedAt != 0 {
out.Mtime = e.updating.publishedAt
} else {
out.Mtime = e.PublishedAt()
}
return fs.OK
}
func (e *EntityDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if name == "publish" && e.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := e.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
e.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
return nil, nil, 0, syscall.ENOTSUP
}
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
switch name {
case "content" + kindToExtension(e.event.Kind):
e.updating.content = e.event.Content
return syscall.ENOTDIR
case "title":
e.updating.title = e.Title()
return syscall.ENOTDIR
default:
return syscall.EINTR
}
}
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
e.updating.publishedAt = in.Mtime
return fs.OK
}
func (e *EntityDir) OnAdd(_ context.Context) {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
e.AddChild("@author", e.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
e.AddChild("event.json", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
eventj, _ := json.MarshalIndent(e.event, "", " ")
return uint64(e.event.CreatedAt),
uint64(e.event.CreatedAt),
unsafe.String(unsafe.SliceData(eventj), len(eventj))
},
},
fs.StableAttr{},
), true)
e.AddChild("identifier", e.NewPersistentInode(
e.root.ctx,
&fs.MemRegularFile{
Data: []byte(e.event.Tags.GetD()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(e.event.CreatedAt),
Mtime: uint64(e.event.CreatedAt),
Size: uint64(len(e.event.Tags.GetD())),
},
},
fs.StableAttr{},
), true)
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
// read-only
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
},
},
fs.StableAttr{},
), true)
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
},
},
fs.StableAttr{},
), true)
} else {
// writeable
e.updating.title = e.Title()
e.updating.publishedAt = e.PublishedAt()
e.updating.content = e.event.Content
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("title updated")
e.updating.title = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("content updated")
e.updating.content = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
}
var refsdir *fs.Inode
i := 0
for ref := range nip27.Parse(e.event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
e.root.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
var imagesdir *fs.Inode
addImage := func(url string) {
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
e.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
e.root.ctx,
&AsyncFile{
ctx: e.root.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
w := &bytes.Buffer{}
io.Copy(w, resp.Body)
return w.Bytes(), 0
},
},
fs.StableAttr{},
), true)
}
images := nip92.ParseTags(e.event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
}
addImage(imeta.URL)
}
if tag := e.event.Tags.Find("image"); tag != nil {
addImage(tag[1])
}
}
func (e *EntityDir) IsNew() bool {
return e.event.CreatedAt == 0
}
func (e *EntityDir) PublishedAt() uint64 {
if tag := e.event.Tags.Find("published_at"); tag != nil {
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
return publishedAt
}
return uint64(e.event.CreatedAt)
}
func (e *EntityDir) Title() string {
if tag := e.event.Tags.Find("title"); tag != nil {
return tag[1]
}
return ""
}
func (e *EntityDir) handleWrite() {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
if e.publisher.IsRunning() {
log(", timer reset")
}
log(", publishing the ")
if e.IsNew() {
log("new")
} else {
log("updated")
}
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
} else {
log(".\n")
}
if !e.publisher.IsRunning() {
log("- `touch publish` to publish immediately\n")
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
}
e.publisher.Call(func() {
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
log("not modified, publish canceled.\n")
return
}
evt := nostr.Event{
Kind: e.event.Kind,
Content: e.updating.content,
Tags: make(nostr.Tags, len(e.event.Tags)),
CreatedAt: nostr.Now(),
}
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
if e.updating.title != "" {
if titleTag := evt.Tags.Find("title"); titleTag != nil {
titleTag[1] = e.updating.title
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
}
}
// "published_at" tag
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
if publishedAtStr != "0" {
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
publishedAtTag[1] = publishedAtStr
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
}
}
// add "p" tags from people mentioned and "q" tags from events mentioned
for ref := range nip27.Parse(evt.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
tag := ref.Pointer.AsTag()
key := tag[0]
val := tag[1]
if key == "e" || key == "a" {
key = "q"
}
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
evt.Tags = append(evt.Tags, tag)
}
}
// sign and publish
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
log("failed to sign: '%s'.\n", err)
return
}
logverbose("%s\n", evt)
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
if len(relays) == 0 {
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
}
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
e.event = &evt
log("event updated locally.\n")
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
} else {
log("failed.\n")
}
})
}
func (r *NostrRoot) FetchAndCreateEntityDir(
parent fs.InodeEmbedder,
extension string,
pointer nostr.EntityPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEntityDir(parent, event), nil
}
func (r *NostrRoot) CreateEntityDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
fs.StableAttr{Mode: syscall.S_IFDIR},
)
}

241
nostrfs/eventdir.go Normal file
View File

@@ -0,0 +1,241 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip10"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip22"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type EventDir struct {
fs.Inode
ctx context.Context
wd string
evt *nostr.Event
}
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mtime = uint64(e.evt.CreatedAt)
return fs.OK
}
func (r *NostrRoot) FetchAndCreateEventDir(
parent fs.InodeEmbedder,
pointer nostr.EventPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEventDir(parent, event), nil
}
func (r *NostrRoot) CreateEventDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
h := parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
)
h.AddChild("@author", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
eventj, _ := json.MarshalIndent(event, "", " ")
h.AddChild("event.json", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: eventj,
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
h.AddChild("id", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.ID.Hex()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(64),
},
},
fs.StableAttr{},
), true)
h.AddChild("content.txt", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
var refsdir *fs.Inode
i := 0
for ref := range nip27.Parse(event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
var imagesdir *fs.Inode
images := nip92.ParseTags(event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
}
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
r.ctx,
&AsyncFile{
ctx: r.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
if err != nil {
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, 0
}
w := &bytes.Buffer{}
io.Copy(w, resp.Body)
return w.Bytes(), 0
},
},
fs.StableAttr{},
), true)
}
if event.Kind == 1 {
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
} else if event.Kind == 1111 {
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
}
return h
}

16
nostrfs/helpers.go Normal file
View File

@@ -0,0 +1,16 @@
package nostrfs
import (
"fiatjaf.com/nostr"
)
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "adoc"
default:
return "txt"
}
}

261
nostrfs/npubdir.go Normal file
View File

@@ -0,0 +1,261 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"sync/atomic"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/liamg/magic"
)
type NpubDir struct {
fs.Inode
root *NostrRoot
pointer nostr.ProfilePointer
fetched atomic.Bool
}
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
func (r *NostrRoot) CreateNpubDir(
parent fs.InodeEmbedder,
pointer nostr.ProfilePointer,
signer nostr.Signer,
) *fs.Inode {
npubdir := &NpubDir{root: r, pointer: pointer}
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
npubdir,
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
)
}
func (h *NpubDir) OnAdd(_ context.Context) {
log := h.root.ctx.Value("log").(func(msg string, args ...any))
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
log("- adding folder for %s with relays %s\n",
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
h.AddChild("pubkey", h.NewPersistentInode(
h.root.ctx,
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
fs.StableAttr{},
), true)
go func() {
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
if pm.Event == nil {
return
}
metadataj, _ := json.MarshalIndent(pm, "", " ")
h.AddChild(
"metadata.json",
h.NewPersistentInode(
h.root.ctx,
&fs.MemRegularFile{
Data: metadataj,
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
),
true,
)
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode < 300 {
b := &bytes.Buffer{}
io.Copy(b, resp.Body)
ext := "png"
if ft, err := magic.Lookup(b.Bytes()); err == nil {
ext = ft.Extension
}
h.AddChild("picture."+ext, h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: b.Bytes(),
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
), true)
}
}
}
}()
if h.GetChild("notes") == nil {
h.AddChild(
"notes",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("comments") == nil {
h.AddChild(
"comments",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1111},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("photos") == nil {
h.AddChild(
"photos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{20},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("videos") == nil {
h.AddChild(
"videos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{21, 22},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("highlights") == nil {
h.AddChild(
"highlights",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{9802},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("articles") == nil {
h.AddChild(
"articles",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30023},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("wiki") == nil {
h.AddChild(
"wiki",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30818},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
}

130
nostrfs/root.go Normal file
View File

@@ -0,0 +1,130 @@
package nostrfs
import (
"context"
"path/filepath"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type Options struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type NostrRoot struct {
fs.Inode
ctx context.Context
wd string
sys *sdk.System
rootPubKey nostr.PubKey
signer nostr.Signer
opts Options
}
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
pubkey, _ := user.GetPublicKey(ctx)
abs, _ := filepath.Abs(mountpoint)
var signer nostr.Signer
if user != nil {
signer, _ = user.(nostr.Signer)
}
return &NostrRoot{
ctx: ctx,
sys: sys,
rootPubKey: pubkey,
signer: signer,
wd: abs,
opts: o,
}
}
func (r *NostrRoot) OnAdd(_ context.Context) {
if r.rootPubKey == nostr.ZeroPK {
return
}
go func() {
time.Sleep(time.Millisecond * 100)
// add our contacts
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
for _, f := range fl.Items {
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
r.AddChild(
nip19.EncodeNpub(f.Pubkey),
r.CreateNpubDir(r, pointer, nil),
true,
)
}
// add ourselves
npub := nip19.EncodeNpub(r.rootPubKey)
if r.GetChild(npub) == nil {
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
r.AddChild(
npub,
r.CreateNpubDir(r, pointer, r.signer),
true,
)
}
// add a link to ourselves
r.AddChild("@me", r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}()
}
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
out.SetEntryTimeout(time.Minute * 5)
child := r.GetChild(name)
if child != nil {
return child, fs.OK
}
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
return r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
fs.StableAttr{Mode: syscall.S_IFLNK},
), fs.OK
}
pointer, err := nip19.ToPointer(name)
if err != nil {
return nil, syscall.ENOENT
}
switch p := pointer.(type) {
case nostr.ProfilePointer:
npubdir := r.CreateNpubDir(r, p, nil)
return npubdir, fs.OK
case nostr.EventPointer:
eventdir, err := r.FetchAndCreateEventDir(r, p)
if err != nil {
return nil, syscall.ENOENT
}
return eventdir, fs.OK
default:
return nil, syscall.ENOENT
}
}

267
nostrfs/viewdir.go Normal file
View File

@@ -0,0 +1,267 @@
package nostrfs
import (
"context"
"strings"
"sync/atomic"
"syscall"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type ViewDir struct {
fs.Inode
root *NostrRoot
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
replaceable bool
createable bool
publisher *debouncer.Debouncer
publishing struct {
note string
}
}
var (
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
_ = (fs.NodeCreater)((*ViewDir)(nil))
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
)
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
return fs.OK
}
func (n *ViewDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return nil, nil, 0, syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return nil, nil, 0, syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
if n.publisher.IsRunning() {
log("pending note updated, timer reset.")
} else {
log("new note detected")
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
} else {
log(".\n")
}
log("- `touch publish` to publish immediately\n")
log("- `rm new` to erase and cancel the publication.\n")
}
n.publisher.Call(n.publishNote)
first := true
return n.NewPersistentInode(
n.root.ctx,
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
if !first {
log("pending note updated, timer reset.\n")
}
first = false
n.publishing.note = strings.TrimSpace(s)
n.publisher.Call(n.publishNote)
}),
fs.StableAttr{},
), nil, 0, fs.OK
case "publish":
if n.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
n.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
}
return nil, nil, 0, syscall.ENOTSUP
}
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing canceled.\n")
n.publisher.Stop()
n.publishing.note = ""
return fs.OK
}
return syscall.ENOTSUP
}
func (n *ViewDir) publishNote() {
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing note...\n")
evt := nostr.Event{
Kind: 1,
CreatedAt: nostr.Now(),
Content: n.publishing.note,
Tags: make(nostr.Tags, 0, 2),
}
// our write relays
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
if len(relays) == 0 {
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
}
// massage and extract tags from raw text
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
relays = nostr.AppendUnique(relays, targetRelays...)
// sign and publish
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
log("failed to sign: %s\n", err)
return
}
log(evt.String() + "\n")
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
n.RmChild("new")
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
}
}
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
now := nostr.Now()
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
out.Mtime = uint64(aMonthAgo)
return fs.OK
}
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
if n.fetched.CompareAndSwap(true, true) {
return fs.OK
}
if n.paginate {
now := nostr.Now()
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
n.filter.Since = aMonthAgo
filter := n.filter
filter.Until = aMonthAgo
n.AddChild("@previous", n.NewPersistentInode(
n.root.ctx,
&ViewDir{
root: n.root,
filter: filter,
relays: n.relays,
replaceable: n.replaceable,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
), true)
}
if n.replaceable {
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
Label: "nakfs",
}).Range {
name := rkey.D
if name == "" {
name = "_"
}
if n.GetChild(name) == nil {
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
}
}
} else {
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
nostr.SubscriptionOptions{
Label: "nakfs",
}) {
if n.GetChild(ie.Event.ID.Hex()) == nil {
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
}
}
}
return fs.OK
}
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
return nil, syscall.ENOTSUP
}
if n.replaceable {
// create a template event that can later be modified and published as new
return n.root.CreateEntityDir(n, &nostr.Event{
PubKey: n.root.rootPubKey,
CreatedAt: 0,
Kind: n.filter.Kinds[0],
Tags: nostr.Tags{
nostr.Tag{"d", name},
},
}), fs.OK
}
return nil, syscall.ENOTSUP
}

93
nostrfs/writeablefile.go Normal file
View File

@@ -0,0 +1,93 @@
package nostrfs
import (
"context"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WriteableFile struct {
fs.Inode
root *NostrRoot
mu sync.Mutex
data []byte
attr fuse.Attr
onWrite func(string)
}
var (
_ = (fs.NodeOpener)((*WriteableFile)(nil))
_ = (fs.NodeReader)((*WriteableFile)(nil))
_ = (fs.NodeWriter)((*WriteableFile)(nil))
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
)
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
return &WriteableFile{
root: r,
data: []byte(data),
attr: fuse.Attr{
Mode: 0666,
Ctime: ctime,
Mtime: mtime,
Size: uint64(len(data)),
},
onWrite: onWrite,
}
}
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
offset := int(off)
end := offset + len(data)
if len(f.data) < end {
newData := make([]byte, offset+len(data))
copy(newData, f.data)
f.data = newData
}
copy(f.data[offset:], data)
f.data = f.data[0:end]
f.onWrite(string(f.data))
return uint32(len(data)), fs.OK
}
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.attr
out.Attr.Size = uint64(len(f.data))
return fs.OK
}
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
f.attr.Mtime = in.Mtime
f.attr.Atime = in.Atime
f.attr.Ctime = in.Ctime
return fs.OK
}
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
end := int(off) + len(dest)
if end > len(f.data) {
end = len(f.data)
}
return fuse.ReadResultData(f.data[off:end]), fs.OK
}

View File

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

309
nsite.go Normal file
View File

@@ -0,0 +1,309 @@
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: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "upload",
Usage: "uploads site files and publishes manifest event",
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&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: "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.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),
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()
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing manifest event: %w", err)
}
relayURLs := nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), c.Args().Slice()[1:]...)
if len(relayURLs) == 0 {
return fmt.Errorf("no relays to publish this nsite to")
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(string, ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayURLs, nil)
if len(relays) == 0 {
return fmt.Errorf("failed to connect to any of [ %v ]", relayURLs)
}
stdout(evt.String())
if identifier == "" {
stdout(nip19.EncodeNpub(pk))
} else {
stdout(nip5a.PubKeyToBase36(pk) + identifier)
}
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
},
},
},
}

102
profile.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"context"
"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`,
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)
npub := nip19.EncodeNpub(pk)
stdout(colors.bold("pubkey (hex):"), pk.Hex())
stdout(colors.bold("npub:"), color.HiCyanString(npub))
relayList := sys.FetchRelayList(ctx, pk)
writeRelays := make([]string, 0, 3)
for _, rl := range relayList.Items {
if rl.Outbox {
writeRelays = append(writeRelays, rl.URL)
if len(writeRelays) == 3 {
break
}
}
}
if len(writeRelays) > 0 {
nprofile := nip19.EncodeNprofile(pk, writeRelays)
stdout(colors.bold("profile uri:"), color.HiCyanString("nostr:"+nprofile))
}
if pm.Name != "" {
stdout(colors.bold("name:"), color.HiBlueString(pm.Name))
}
if pm.DisplayName != "" {
stdout(colors.bold("display_name:"), color.HiBlueString(pm.DisplayName))
}
if pm.About != "" {
stdout(colors.bold("about:"), color.HiBlueString(pm.About))
}
if pm.Picture != "" {
stdout(colors.bold("picture:"), color.HiBlueString(pm.Picture))
}
if pm.Banner != "" {
stdout(colors.bold("banner:"), color.HiBlueString(pm.Banner))
}
if pm.Website != "" {
stdout(colors.bold("website:"), color.HiBlueString(pm.Website))
}
if pm.NIP05 != "" {
isValid := pm.NIP05Valid(ctx)
if isValid {
stdout(colors.bold("nip05:"), color.HiGreenString(pm.NIP05), color.HiGreenString("(verified)"))
} else {
stdout(colors.bold("nip05:"), color.HiRedString(pm.NIP05), color.HiRedString("(not verified)"))
}
}
if pm.LUD16 != "" {
stdout(colors.bold("lud16:"), color.HiBlueString(pm.LUD16))
}
if len(relayList.Items) > 0 {
stdout(colors.bold("relays:"))
for _, relay := range relayList.Items {
access := ""
if relay.Inbox && relay.Outbox {
access = "read/write"
} else if relay.Inbox {
access = "read"
} else if relay.Outbox {
access = "write"
}
stdout(" ", color.HiBlueString(relay.URL), color.HiCyanString("(%s)", access))
}
}
followList := sys.FetchFollowList(ctx, pk)
contactCount := len(followList.Items)
stdout(colors.bold("follows:"), color.HiCyanString("%d", contactCount))
}
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -91,6 +91,8 @@ example:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
default:
return fmt.Errorf("unexpected reply target type: %T", value)
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
@@ -109,6 +111,7 @@ example:
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
// add reply tags
@@ -151,13 +154,11 @@ example:
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)
},
},
)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
if len(relayUrls) == 0 {

View File

@@ -24,7 +24,7 @@ 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
}

312
req.go
View File

@@ -44,6 +44,14 @@ example:
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
@@ -60,7 +68,7 @@ example:
},
&cli.BoolFlag{
Name: "outbox",
Usage: "use outbox relays from specified public keys",
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{
@@ -100,6 +108,10 @@ example:
),
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,6 +137,14 @@ example:
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
jq, err := jqPrepare(c.String("jq"))
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
@@ -132,25 +152,25 @@ example:
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
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 +215,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 +229,7 @@ example:
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
jq: jq,
}
var source nostr.Querier = nil
@@ -235,7 +259,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 +303,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 +318,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 +343,96 @@ 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
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
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
}
// otherwise we'll just use the idx
mu.Unlock()
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue
}
mu.Unlock()
}
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
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
}
return nil
})
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
})
}
errg.Wait()
}
errg.Wait()
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
@@ -367,25 +454,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, _ = json.MarshalToString(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",
@@ -424,6 +530,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"},
@@ -449,10 +560,30 @@ var reqFilterFlags = []cli.Flag{
},
}
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...)
}
@@ -462,23 +593,30 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
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 +624,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 +648,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, _ = json.MarshalToString(v)
}
stdout(out)
return nil
} else if err == eventstore.ErrDupEvent {
return nil

120
serve.go
View File

@@ -6,9 +6,12 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"time"
@@ -105,13 +108,65 @@ 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)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
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,9 +217,13 @@ 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
@@ -191,61 +250,6 @@ var serve = &cli.Command{
return false, ""
}
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
d := debounce.New(time.Second * 2)
printStatus = func() {
d(func() {
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
blossomMsg := ""
if c.Bool("blossom") {
blobsStored := blobStore.Size()
blossomMsg = fmt.Sprintf("blobs: %s, ",
color.HiMagentaString("%d", blobsStored),
)
}
graspMsg := ""
if c.Bool("grasp") {
gitAnnounced := 0
gitStored := 0
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
gitAnnounced++
identifier := evt.Tags.GetD()
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
gitStored++
}
}
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
color.HiMagentaString("%d", gitAnnounced),
color.HiMagentaString("%d", gitStored),
)
}
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
blossomMsg,
graspMsg,
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
})
}
<-started
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
if c.Bool("grasp") {
log(" (grasp repos at %s)", repoDir)

View File

@@ -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:]

View File

@@ -409,7 +409,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 +420,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 +452,7 @@ func parseMessageBuildNext(
fingerprint := acc.GetFingerprint(numIds)
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
nextMsg.Write(fingerprint[:])
}
default:

321
thread_helpers.go Normal file
View File

@@ -0,0 +1,321 @@
package main
import (
"context"
"fmt"
"io"
"os"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip22"
"github.com/fatih/color"
)
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
filterTags := nostr.TagMap{
"E": []string{discussionID.Hex()},
}
for key, values := range extraTags {
filterTags[key] = values
}
comments := make([]nostr.RelayEvent, 0, 15)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{1111},
Tags: filterTags,
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
comments = append(comments, ie)
}
slices.SortFunc(comments, nostr.CompareRelayEvent)
return comments, nil
}
func showThreadWithComments(
ctx context.Context,
relays []string,
evt nostr.RelayEvent,
status string,
extraTags nostr.TagMap,
) error {
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
if err != nil {
return err
}
printThreadMetadata(ctx, os.Stdout, evt, status, true)
stdout("")
stdout(evt.Content)
if len(comments) > 0 {
stdout("")
stdout(color.CyanString("comments:"))
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
}
return nil
}
func printThreadedComments(
ctx context.Context,
w io.Writer,
comments []nostr.RelayEvent,
discussionID nostr.ID,
withColor bool,
) {
byID := make(map[nostr.ID]struct{}, len(comments)+1)
byID[discussionID] = struct{}{}
for _, c := range comments {
byID[c.ID] = struct{}{}
}
// preload metadata from everybody
wg := sync.WaitGroup{}
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
for _, c := range comments {
wg.Go(func() {
sys.FetchProfileMetadata(ctx, c.PubKey)
})
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
if !ok {
continue
}
if _, ok := byID[parent.ID]; ok {
children[parent.ID] = append(children[parent.ID], c)
}
}
for parent := range children {
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
}
wg.Wait()
var render func(parent nostr.ID, depth int)
render = func(parent nostr.ID, depth int) {
for _, c := range children[parent] {
indent := strings.Repeat(" ", depth)
author := authorPreview(ctx, c.PubKey)
created := c.CreatedAt.Time().Format(time.DateTime)
if withColor {
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
} else {
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
}
for _, line := range strings.Split(c.Content, "\n") {
fmt.Fprintln(w, indent+" "+line)
}
fmt.Fprintln(w, indent+"")
render(c.ID, depth+1)
}
}
render(discussionID, 0)
}
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
}
matchCount := 0
matched := nostr.RelayEvent{}
for _, evt := range events {
if strings.HasPrefix(evt.ID.Hex(), prefix) {
matched = evt
matchCount++
}
}
if matchCount == 0 {
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
}
if matchCount > 1 {
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
}
return matched, nil
}
func printThreadMetadata(
ctx context.Context,
w io.Writer,
evt nostr.RelayEvent,
status string,
withColors bool,
) {
label := func(s string) string { return s }
value := func(s string) string { return s }
statusValue := func(s string) string { return s }
if withColors {
label = func(s string) string { return color.CyanString(s) }
value = func(s string) string { return color.HiWhiteString(s) }
statusValue = colorizeGitStatus
}
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
if status != "" {
fmt.Fprintln(w, label("status:"), statusValue(status))
}
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
fmt.Fprintln(w, label("subject:"), value(subject[1]))
fmt.Fprintln(w, "")
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
fmt.Fprintln(w, label("title:"), value(title[1]))
fmt.Fprintln(w, "")
}
}
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
currentParent := discussion
selectedParent := nostr.ZeroID
inComments := false
replyb := strings.Builder{}
for _, line := range strings.Split(edited, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
inComments = false
currentParent = discussion
continue
}
if replyb.Len() == 0 && line == "" {
continue
}
if strings.HasPrefix(line, "#>") {
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
if quoted == "comments:" {
inComments = true
currentParent = discussion
continue
}
// keep track of which comment the reply body shows up below of
// so we can assign it as a reply to that specifically
fields := strings.Fields(quoted)
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
currId := fields[0][1 : len(fields[0])-1]
for _, comment := range comments {
if strings.HasPrefix(comment.ID.Hex(), currId) {
currentParent = comment
break
}
}
}
continue
}
// if we reach here this is a line for the reply input from the user
replyb.WriteString(line)
replyb.WriteByte('\n')
if line == "" {
continue
}
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
}
selectedParent = currentParent.ID
}
content := strings.TrimSpace(replyb.String())
if content == "" {
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
}
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
return content, discussion, nil
}
for _, comment := range comments {
if comment.ID == selectedParent {
return content, comment, nil
}
}
panic("selected reply parent not found (this never happens)")
}
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
lines := make([]string, 0, len(headerLines)+3)
for _, line := range headerLines {
if strings.TrimSpace(line) == "" {
continue
}
lines = append(lines, "# "+strings.TrimSpace(line))
}
lines = append(lines,
"# write your reply here.",
"# lines starting with '#' are ignored.",
"",
)
appender := &lineAppender{lines, "#> "}
printThreadMetadata(ctx, appender, discussion, "", false)
for _, line := range strings.Split(discussion.Content, "\n") {
appender.lines = append(appender.lines, "#> "+line)
}
if len(comments) > 0 {
appender.lines = append(appender.lines, "#> ", "#> comments:")
printThreadedComments(ctx, appender, comments, discussion.ID, false)
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
}
return strings.Join(appender.lines, "\n")
}
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return nostr.ZeroPK, "", "", err
}
meta := sys.FetchProfileMetadata(ctx, pk)
return pk, meta.ShortName(), meta.NpubShort(), nil
}
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
meta := sys.FetchProfileMetadata(ctx, pubkey)
if meta.Name != "" {
return meta.ShortName() + " (" + meta.NpubShort() + ")"
}
return meta.NpubShort()
}
type lineAppender struct {
lines []string
prefix string
}
func (l *lineAppender) Write(b []byte) (int, error) {
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
line = strings.TrimRight(line, " ")
l.lines = append(l.lines, l.prefix+line)
}
return len(b), nil
}

81
validate.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"context"
"fmt"
"strings"
"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: "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml",
TakesFile: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
var validator schema.Validator
if schemaURL := c.String("schema"); strings.HasPrefix(schemaURL, "http") {
var err error
validator, err = schema.NewValidatorFromURL(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from '%s': %w", schemaURL, err)
}
} else {
var err error
validator, err = schema.NewValidatorFromFile(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from %s: %w", schemaURL, err)
}
}
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 == "" {
for _, arg := range c.Args().Slice() {
if err := handleEvent(arg); err != nil {
ctx = lineProcessingError(ctx, "%s", err)
}
}
continue
}
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, "%s", err)
}
}
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -27,23 +28,35 @@ it outputs nothing if the verification is successful.`,
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event: %s", err)
logverbose("<>: invalid event.\n", evt.ID.Hex())
logverbose("%s\n", color.RedString("<>: invalid event."))
continue
}
if evt.GetID() != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
logverbose("%s: invalid id.\n", evt.ID.Hex())
impliedID := evt.GetID()
idsMatch := impliedID == evt.ID
logverbose(
"%s\n%s %s\n%s %s\n%s %s\n%s %s\n%s %s\n",
color.CyanString("verifying event:"),
color.BlueString(" event: "), stdinEvent,
color.BlueString(" given id: "), color.YellowString("%s", evt.ID),
color.BlueString(" serialized:"), string(evt.Serialize()),
color.BlueString(" implied id:"), color.YellowString("%s", impliedID),
color.BlueString(" ids match: "), color.New(map[bool]color.Attribute{true: color.FgGreen, false: color.FgRed}[idsMatch]).Sprint(idsMatch),
)
if impliedID != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", impliedID, evt.ID)
logverbose("%s\n", color.RedString("invalid id: %s", evt.ID.Hex()))
continue
}
if !evt.VerifySignature() {
ctx = lineProcessingError(ctx, "invalid signature")
logverbose("%s: invalid signature.\n", evt.ID.Hex())
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
continue
}
logverbose("%s: valid.\n", evt.ID.Hex())
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
}
exitIfLineProcessingError(ctx)

View File

@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
relays := sys.FetchOutboxRelays(ctx, pk, 3)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
return nil, nil, fmt.Errorf("error loading wallet")
}
w.Processed = func(evt nostr.Event, err error) {
@@ -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)