mirror of
https://github.com/fiatjaf/nak.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
176 Commits
v0.17.2
...
fix/group-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df857a076 | ||
|
|
52cf03d423 | ||
|
|
d0c291b25c | ||
|
|
6ee60cb197 | ||
|
|
c2eb2d7bdd | ||
|
|
168a182d50 | ||
|
|
f202f038a4 | ||
|
|
1043b2a7af | ||
|
|
9c3b4b69b6 | ||
|
|
1c542deb5e | ||
|
|
cefc0b91d0 | ||
|
|
8c97c7becb | ||
|
|
ada938c3a8 | ||
|
|
c2d74b846e | ||
|
|
06bd1ddb11 | ||
|
|
ebd93964de | ||
|
|
a285ac6723 | ||
|
|
e2f791ab85 | ||
|
|
ed5af14305 | ||
|
|
e5f4b07c3d | ||
|
|
b41260f663 | ||
|
|
c9e1511865 | ||
|
|
5af1d111ba | ||
|
|
2922a0de25 | ||
|
|
35d628ef6d | ||
|
|
a2f109c8c8 | ||
|
|
d9cc9f1d80 | ||
|
|
d97937eaa4 | ||
|
|
bdd3aa1093 | ||
|
|
02c12cb671 | ||
|
|
c04add227d | ||
|
|
c3f1ef7877 | ||
|
|
d0e719fb93 | ||
|
|
187842214e | ||
|
|
405be6efa9 | ||
|
|
47e462fe55 | ||
|
|
99ef7a8c91 | ||
|
|
a152d7f633 | ||
|
|
25d2045640 | ||
|
|
09b9939956 | ||
|
|
2a8cd898c2 | ||
|
|
eae828e03b | ||
|
|
9ae5798a24 | ||
|
|
5930437578 | ||
|
|
2193491c3b | ||
|
|
d3f4548dbd | ||
|
|
07b791e90a | ||
|
|
2c3d72b337 | ||
|
|
46c68462db | ||
|
|
0834420d89 | ||
|
|
217352ed0b | ||
|
|
318838b3ff | ||
|
|
b811396d8b | ||
|
|
2bc1cf3417 | ||
|
|
65ddf7b821 | ||
|
|
e19306b124 | ||
|
|
e059fba487 | ||
|
|
00f44958de | ||
|
|
e37ea23a8d | ||
|
|
3c4cb533b4 | ||
|
|
be26dfca3c | ||
|
|
26518ec260 | ||
|
|
de599c64f6 | ||
|
|
3d650ee502 | ||
|
|
67920ccbdd | ||
|
|
0dbe14aa93 | ||
|
|
ec1721cfe3 | ||
|
|
3b318fec54 | ||
|
|
82275e4b9e | ||
|
|
ae98e99ec6 | ||
|
|
29db04b7ea | ||
|
|
59fecacbdc | ||
|
|
faac4d9440 | ||
|
|
f00a4a7d2a | ||
|
|
d823ac55a6 | ||
|
|
105ccef7ec | ||
|
|
9d4df21836 | ||
|
|
952d638ac3 | ||
|
|
c1e72e0af1 | ||
|
|
5f4efdbc69 | ||
|
|
4967db13a1 | ||
|
|
da0b753371 | ||
|
|
bef67d35d2 | ||
|
|
a8fb2e4189 | ||
|
|
7aea4cf9a1 | ||
|
|
d472efe707 | ||
|
|
17341b3af6 | ||
|
|
ff5a7b4ba7 | ||
|
|
7596e317b8 | ||
|
|
da7fc3fac3 | ||
|
|
0e8475b388 | ||
|
|
5532c884bc | ||
|
|
05857ab190 | ||
|
|
bf22a63404 | ||
|
|
6bd2d1cdfc | ||
|
|
9c8d59c5c5 | ||
|
|
5957c08d15 | ||
|
|
f59c8a670d | ||
|
|
61a3b89d08 | ||
|
|
d61fdc4cb4 | ||
|
|
0735ded0fc | ||
|
|
5233a77510 | ||
|
|
fd34cc7c5e | ||
|
|
2d151c2ac8 | ||
|
|
037e8efcc6 | ||
|
|
1b380dea9a | ||
|
|
f126b3f7ee | ||
|
|
dc5ffe5129 | ||
|
|
7637b5018f | ||
|
|
d5ab34bb2f | ||
|
|
49345333c4 | ||
|
|
b5de7b78bc | ||
|
|
ba9a5badc6 | ||
|
|
c0bbf73961 | ||
|
|
5320feee4f | ||
|
|
58c1fab0f0 | ||
|
|
5f30009e72 | ||
|
|
548918578b | ||
|
|
7757400ab3 | ||
|
|
3ee6320312 | ||
|
|
91474d65eb | ||
|
|
7d782737c4 | ||
|
|
9160c68cb5 | ||
|
|
bf19f38996 | ||
|
|
4e2c136e45 | ||
|
|
8cef1ed0ea | ||
|
|
e05b455a05 | ||
|
|
9190c9d988 | ||
|
|
e64ad8f078 | ||
|
|
b36718caaa | ||
|
|
5c658c38f1 | ||
|
|
2a5ce3b249 | ||
|
|
c0b85af734 | ||
|
|
cb2247c9da | ||
|
|
686d960f62 | ||
|
|
af04838153 | ||
|
|
c6da13649d | ||
|
|
acd6227dd0 | ||
|
|
00fbda9af7 | ||
|
|
e838de9b72 | ||
|
|
6dfbed4413 | ||
|
|
0e283368ed | ||
|
|
38775e0d93 | ||
|
|
fabcad3f61 | ||
|
|
69e4895e48 | ||
|
|
81524de04f | ||
|
|
8334474f96 | ||
|
|
87f27e214e | ||
|
|
32999917b4 | ||
|
|
a19a179548 | ||
|
|
9b684f2c65 | ||
|
|
6d87887855 | ||
|
|
e9c4deaf6d | ||
|
|
965a312b46 | ||
|
|
2e4079f92c | ||
|
|
5b64795015 | ||
|
|
5d4fe434c3 | ||
|
|
b95665d986 | ||
|
|
3be80c29df | ||
|
|
e01cfbde47 | ||
|
|
e91d4429ec | ||
|
|
21423b4a21 | ||
|
|
1b7f3162b5 | ||
|
|
8f38468103 | ||
|
|
9bf728d850 | ||
|
|
8396738fe2 | ||
|
|
c1d1682d6e | ||
|
|
6f00ff4c73 | ||
|
|
68bbece3db | ||
|
|
a83b23d76b | ||
|
|
a288cc47a4 | ||
|
|
5ee7670ba8 | ||
|
|
b973b476bc | ||
|
|
252612b12f | ||
|
|
4b8b6bb3de | ||
|
|
df491be232 |
90
.github/workflows/release-cli.yml
vendored
90
.github/workflows/release-cli.yml
vendored
@@ -47,3 +47,93 @@ jobs:
|
||||
md5sum: false
|
||||
sha256sum: false
|
||||
compress_assets: false
|
||||
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-all-for-all
|
||||
steps:
|
||||
- name: download and smoke test latest binary
|
||||
run: |
|
||||
set -eo pipefail # exit on error, and on pipe failures
|
||||
|
||||
echo "downloading nak binary from releases"
|
||||
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
|
||||
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
|
||||
chmod +x nak
|
||||
|
||||
echo "printing version..."
|
||||
./nak --version
|
||||
|
||||
# generate and manipulate keys
|
||||
echo "testing key operations..."
|
||||
SECRET_KEY=$(./nak key generate)
|
||||
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
|
||||
echo "generated key pair: $SECRET_KEY => $PUBLIC_KEY"
|
||||
|
||||
# create events
|
||||
echo "testing event creation..."
|
||||
./nak event -c "hello world"
|
||||
HELLOWORLD=$(./nak event -c "hello world")
|
||||
echo " hello world again: $HELLOWORLD"
|
||||
./nak event --ts "2 days ago" -c "event with timestamp"
|
||||
./nak event -k 1 -t "t=test" -c "event with tag"
|
||||
|
||||
# test NIP-19 encoding/decoding
|
||||
echo "testing NIP-19 encoding/decoding..."
|
||||
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
|
||||
echo "encoded nsec: $NSEC"
|
||||
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||
EVENT_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
|
||||
NEVENT1=$(./nak encode nevent $EVENT_ID)
|
||||
echo "encoded nevent1: $NEVENT1"
|
||||
./nak decode $NEVENT1
|
||||
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
|
||||
|
||||
# test event verification
|
||||
echo "testing event verification..."
|
||||
# create an event and verify it
|
||||
VERIFY_EVENT=$(./nak event -c "verify me")
|
||||
echo $VERIFY_EVENT | ./nak verify
|
||||
|
||||
# test PoW
|
||||
echo "testing pow..."
|
||||
./nak event -c "testing pow" --pow 8
|
||||
|
||||
# test NIP-49 key encryption/decryption
|
||||
echo "testing NIP-49 key encryption/decryption..."
|
||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||
echo "nip-49 encryption/decryption test failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# test multi-value tags
|
||||
echo "testing multi-value tags..."
|
||||
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "testing multi-value tags"
|
||||
|
||||
# test relay operations (with a public relay)
|
||||
echo "testing publishing..."
|
||||
# publish a simple event to a public relay
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
|
||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||
echo "published event ID: $EVENT_ID"
|
||||
|
||||
# wait a moment for propagation
|
||||
sleep 2
|
||||
|
||||
# fetch the event we just published
|
||||
./nak req -i $EVENT_ID nos.lol
|
||||
|
||||
# test serving (just start and immediately kill)
|
||||
echo "testing serve command..."
|
||||
timeout 2s ./nak serve || true
|
||||
|
||||
# test filesystem mount (just start and immediately kill)
|
||||
echo "testing fs mount command..."
|
||||
mkdir -p /tmp/nostr-mount
|
||||
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
|
||||
|
||||
echo "all tests passed"
|
||||
|
||||
97
.github/workflows/smoke-test-release.yml
vendored
97
.github/workflows/smoke-test-release.yml
vendored
@@ -1,97 +0,0 @@
|
||||
name: Smoke test the binary
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["build cli for all platforms"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download and smoke test latest binary
|
||||
run: |
|
||||
set -eo pipefail # Exit on error, and on pipe failures
|
||||
|
||||
echo "Downloading nak binary from releases"
|
||||
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
|
||||
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
|
||||
chmod +x nak
|
||||
|
||||
echo "Running basic tests..."
|
||||
./nak --version
|
||||
|
||||
# Generate and manipulate keys
|
||||
echo "Testing key operations..."
|
||||
SECRET_KEY=$(./nak key generate)
|
||||
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
|
||||
echo "Generated key pair: $PUBLIC_KEY"
|
||||
|
||||
# Create events
|
||||
echo "Testing event creation..."
|
||||
./nak event -c "hello world"
|
||||
./nak event --ts "2 days ago" -c "event with timestamp"
|
||||
./nak event -k 1 -t "t=test" -c "event with tag"
|
||||
|
||||
# Test NIP-19 encoding/decoding
|
||||
echo "Testing NIP-19 encoding/decoding..."
|
||||
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
|
||||
echo "Encoded nsec: $NSEC"
|
||||
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||
NOTE_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
|
||||
NOTE1=$(./nak encode note $NOTE_ID)
|
||||
echo "Encoded note1: $NOTE1"
|
||||
./nak decode $NOTE1
|
||||
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
|
||||
|
||||
# Test event verification
|
||||
echo "Testing event verification..."
|
||||
# Create an event and verify it
|
||||
VERIFY_EVENT=$(./nak event -c "verify me")
|
||||
echo $VERIFY_EVENT | ./nak verify
|
||||
|
||||
# Test PoW
|
||||
echo "Testing PoW..."
|
||||
./nak event -c "testing pow" --pow 8
|
||||
|
||||
# Test NIP-49 key encryption/decryption
|
||||
echo "Testing NIP-49 key encryption/decryption..."
|
||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||
echo "Encrypted key: ${ENCRYPTED_KEY:0:20}..."
|
||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||
echo "NIP-49 encryption/decryption test failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test multi-value tags
|
||||
echo "Testing multi-value tags..."
|
||||
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "Testing multi-value tags"
|
||||
|
||||
# Test relay operations (with a public relay)
|
||||
echo "Testing relay operations..."
|
||||
# Publish a simple event to a public relay
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "Test from nak smoke test" nos.lol)
|
||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||
echo "Published event ID: $EVENT_ID"
|
||||
|
||||
# Wait a moment for propagation
|
||||
sleep 2
|
||||
|
||||
# Fetch the event we just published
|
||||
./nak req -i $EVENT_ID nos.lol
|
||||
|
||||
# Test serving (just start and immediately kill)
|
||||
echo "Testing serve command..."
|
||||
timeout 2s ./nak serve || true
|
||||
|
||||
# Test filesystem mount (just start and immediately kill)
|
||||
echo "Testing fs mount command..."
|
||||
mkdir -p /tmp/nostr-mount
|
||||
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
|
||||
|
||||
echo "All tests passed"
|
||||
@@ -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
|
||||
|
||||
127
blossom.go
127
blossom.go
@@ -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
|
||||
}
|
||||
|
||||
393
bunker.go
393
bunker.go
@@ -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,29 +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.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
|
||||
@@ -343,57 +443,66 @@ var bunker = &cli.Command{
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
||||
return false
|
||||
}
|
||||
|
||||
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) {
|
||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
||||
err := relay.Publish(ctx, eventResponse)
|
||||
printLock.Lock()
|
||||
if err == nil {
|
||||
log("* sent response through %s\n", relay.URL)
|
||||
} else {
|
||||
log("* failed to send response: %s\n", err)
|
||||
}
|
||||
printLock.Unlock()
|
||||
handlerWg.Done()
|
||||
}
|
||||
}(relayURL)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -404,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
|
||||
@@ -489,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
220
count.go
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
6
curl.go
6
curl.go
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
435
dekey.go
Normal file
435
dekey.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var dekey = &cli.Command{
|
||||
Name: "dekey",
|
||||
Usage: "handles NIP-4E decoupled encryption keys",
|
||||
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Usage: "name of this device that will be published and displayed on other clients",
|
||||
Value: func() string {
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
return "nak@" + hostname
|
||||
}
|
||||
return "nak@unknown"
|
||||
}(),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "rotate",
|
||||
Usage: "force the creation of a new decoupled encryption key, effectively invalidating any previous ones",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "authorize-all",
|
||||
Aliases: []string{"yolo"},
|
||||
Usage: "do not ask for confirmation, just automatically send the decoupled encryption key to all devices that exist",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "reject-all",
|
||||
Usage: "do not ask for confirmation, just not send the decoupled encryption key to any device",
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userPub, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user public key: %w", err)
|
||||
}
|
||||
|
||||
configPath := c.String("config-path")
|
||||
deviceName := c.String("device")
|
||||
|
||||
log("handling device key for %s as %s\n",
|
||||
color.YellowString(deviceName),
|
||||
color.CyanString(nip19.EncodeNpub(userPub)),
|
||||
)
|
||||
// check if we already have a local-device secret key
|
||||
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
|
||||
var deviceSec nostr.SecretKey
|
||||
if data, err := os.ReadFile(deviceKeyPath); err == nil {
|
||||
log(color.GreenString("found existing device key\n"))
|
||||
deviceSec, err = nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
|
||||
}
|
||||
} else {
|
||||
log(color.YellowString("generating new device key\n"))
|
||||
// create one
|
||||
deviceSec = nostr.Generate()
|
||||
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
|
||||
if err := os.WriteFile(deviceKeyPath, []byte(deviceSec.Hex()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write device key: %w", err)
|
||||
}
|
||||
log(color.GreenString("device key generated and stored\n"))
|
||||
}
|
||||
devicePub := deviceSec.Public()
|
||||
|
||||
// get relays for the user
|
||||
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
|
||||
relays := sys.FetchWriteRelays(ctx, userPub)
|
||||
relayList := connectToAllRelays(ctx, c, relays, nil)
|
||||
if len(relayList) == 0 {
|
||||
return fmt.Errorf("no relays to use")
|
||||
}
|
||||
|
||||
// check for kind:10044
|
||||
log("- checking for decoupled encryption key (kind:10044)\n")
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
||||
var eSec nostr.SecretKey
|
||||
var ePub nostr.PubKey
|
||||
|
||||
var generateNewEncryptionKey bool
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""})
|
||||
if !ok {
|
||||
log("- no decoupled encryption key found, generating new one\n")
|
||||
generateNewEncryptionKey = true
|
||||
} else {
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||
}
|
||||
|
||||
log(". a decoupled encryption public key already exists: %s\n", color.CyanString(ePub.Hex()))
|
||||
if c.Bool("rotate") {
|
||||
log(color.GreenString("rotating it by generating a new one\n"))
|
||||
generateNewEncryptionKey = true
|
||||
}
|
||||
}
|
||||
|
||||
if generateNewEncryptionKey {
|
||||
// generate main secret key
|
||||
eSec = nostr.Generate()
|
||||
ePub = eSec.Public()
|
||||
|
||||
// store it
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write decoupled encryption key: %w", err)
|
||||
}
|
||||
log("decoupled encryption key generated and stored, public key: %s\n", color.CyanString(ePub.Hex()))
|
||||
|
||||
// publish kind:10044
|
||||
log("publishing decoupled encryption public key (kind:10044)\n")
|
||||
evt10044 := nostr.Event{
|
||||
Kind: 10044,
|
||||
Content: "",
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"n", ePub.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt10044); err != nil {
|
||||
return fmt.Errorf("failed to sign kind:10044: %w", err)
|
||||
}
|
||||
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// check if we have the key
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||
log(color.GreenString("- and we have it locally already\n"))
|
||||
eSec, err = nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||
}
|
||||
} else {
|
||||
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
|
||||
|
||||
// check if our kind:4454 is already published
|
||||
log("- checking for existing device announcement (kind:4454)\n")
|
||||
ourDeviceAnnouncementEvents := make([]nostr.Event, 0, 1)
|
||||
for evt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4454},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
Tags: nostr.TagMap{
|
||||
"P": []string{devicePub.Hex()},
|
||||
},
|
||||
Limit: 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt.Event)
|
||||
}
|
||||
if len(ourDeviceAnnouncementEvents) == 0 {
|
||||
log(". no device announcement found, publishing kind:4454 for %s\n", color.YellowString(deviceName))
|
||||
// publish kind:4454
|
||||
evt := nostr.Event{
|
||||
Kind: 4454,
|
||||
Content: "",
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"client", deviceName},
|
||||
{"P", devicePub.Hex()},
|
||||
},
|
||||
}
|
||||
|
||||
// sign with main key
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("failed to sign device event: %w", err)
|
||||
}
|
||||
|
||||
// publish
|
||||
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
|
||||
return err
|
||||
}
|
||||
log(color.GreenString(". device announcement published\n"))
|
||||
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt)
|
||||
} else {
|
||||
log(color.GreenString(". device already registered\n"))
|
||||
}
|
||||
|
||||
// see if some other device has shared the key with us from kind:4455
|
||||
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4455},
|
||||
Tags: nostr.TagMap{
|
||||
"p": []string{devicePub.Hex()},
|
||||
},
|
||||
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
var senderPub nostr.PubKey
|
||||
for _, tag := range eKeyMsg.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "P" {
|
||||
senderPub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if senderPub == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
ss, err := nip44.GenerateConversationKey(senderPub, deviceSec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eSecHex, err := nip44.Decrypt(eKeyMsg.Content, ss)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eSec, err = nostr.SecretKeyFromHex(eSecHex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// check if it matches mainPub
|
||||
if eSec.Public() == ePub {
|
||||
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
|
||||
// store it
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
||||
|
||||
// delete our 4454 if we had one, since we received the key
|
||||
if len(ourDeviceAnnouncementEvents) > 0 {
|
||||
log("deleting our device announcement (kind:4454) since we received the decoupled encryption key\n")
|
||||
deletion4454 := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", ourDeviceAnnouncementEvents[0].ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion4454); err != nil {
|
||||
log(color.RedString("failed to sign 4454 deletion: %v\n"), err)
|
||||
} else if err := publishFlow(ctx, c, kr, deletion4454, relayList); err != nil {
|
||||
log(color.RedString("failed to publish 4454 deletion: %v\n"), err)
|
||||
} else {
|
||||
log(color.GreenString("- device announcement deleted\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// delete the 4455 we just decrypted
|
||||
log("deleting the key message (kind:4455) we just decrypted\n")
|
||||
deletion4455 := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", eKeyMsg.ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion4455); err != nil {
|
||||
log(color.RedString("failed to sign 4455 deletion: %v\n"), err)
|
||||
} else if err := publishFlow(ctx, c, kr, deletion4455, relayList); err != nil {
|
||||
log(color.RedString("failed to publish 4455 deletion: %v\n"), err)
|
||||
} else {
|
||||
log(color.GreenString("- key message deleted\n"))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if eSec == [32]byte{} {
|
||||
log("decoupled encryption secret key not available, must be sent from another device to %s first\n",
|
||||
color.YellowString(deviceName))
|
||||
return nil
|
||||
}
|
||||
log(color.GreenString("- decoupled encryption key ready\n"))
|
||||
|
||||
// now we have mainSec, check for other kind:4454 events newer than the 10044
|
||||
log("- checking for other devices and key messages so we can send the key\n")
|
||||
keyMsgs := make([]string, 0, 5)
|
||||
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{4454, 4455},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||
if keyOrDeviceEvt.Kind == 4455 {
|
||||
// got key event
|
||||
keyEvent := keyOrDeviceEvt
|
||||
|
||||
// assume a key msg will always come before its associated devicemsg
|
||||
// so just store them here:
|
||||
pubkeyTag := keyEvent.Tags.Find("p")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
keyMsgs = append(keyMsgs, pubkeyTag[1])
|
||||
} else if keyOrDeviceEvt.Kind == 4454 {
|
||||
// device event
|
||||
deviceEvt := keyOrDeviceEvt
|
||||
|
||||
// skip ourselves
|
||||
if deviceEvt.Tags.FindWithValue("P", devicePub.Hex()) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// if there is a clock skew (current time is earlier than the time of this device's announcement) skip it
|
||||
if nostr.Now() < deviceEvt.CreatedAt {
|
||||
continue
|
||||
}
|
||||
|
||||
// if this already has a corresponding keyMsg then skip it
|
||||
pubkeyTag := deviceEvt.Tags.Find("P")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceTag := deviceEvt.Tags.Find("client")
|
||||
if deviceTag == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
|
||||
// so we have to build a keyMsg for them
|
||||
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Bool("authorize-all") {
|
||||
// will proceed
|
||||
} else if c.Bool("reject-all") {
|
||||
log(" - skipping %s\n", color.YellowString(deviceTag[1]))
|
||||
continue
|
||||
} else {
|
||||
var proceed bool
|
||||
if err := survey.AskOne(&survey.Confirm{
|
||||
Message: fmt.Sprintf("share decoupled encryption key with %s"+colors.bold("?"),
|
||||
color.YellowString(deviceTag[1])),
|
||||
}, &proceed); err != nil {
|
||||
return err
|
||||
}
|
||||
if proceed {
|
||||
// will proceed
|
||||
} else {
|
||||
// won't proceed
|
||||
var deleteDevice bool
|
||||
if err := survey.AskOne(&survey.Confirm{
|
||||
Message: fmt.Sprintf(" delete %s"+colors.bold("'s announcement?"), color.YellowString(deviceTag[1])),
|
||||
}, &deleteDevice); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteDevice {
|
||||
log(" - deleting %s\n", color.YellowString(deviceTag[1]))
|
||||
deletion := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 5,
|
||||
Tags: nostr.Tags{
|
||||
{"e", deviceEvt.ID.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &deletion); err != nil {
|
||||
return fmt.Errorf("failed to sign deletion '%s': %w", deletion.GetID().Hex(), err)
|
||||
}
|
||||
if err := publishFlow(ctx, c, kr, deletion, relayList); err != nil {
|
||||
return fmt.Errorf("publish flow failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
log(" - skipped\n")
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log("- sending decoupled encryption key to new device %s\n", color.YellowString(deviceTag[1]))
|
||||
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ciphertext, err := nip44.Encrypt(eSec.Hex(), ss)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
evt4455 := nostr.Event{
|
||||
Kind: 4455,
|
||||
Content: ciphertext,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"p", theirDevice.Hex()},
|
||||
{"P", devicePub.Hex()},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt4455); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
|
||||
log(color.RedString("failed to publish key message: %v\n"), err)
|
||||
} else {
|
||||
log(" - decoupled encryption key sent to %s\n", color.GreenString(deviceTag[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdout(ePub.Hex())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
244
encode.go
244
encode.go
@@ -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,21 +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`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to naddr code",
|
||||
},
|
||||
},
|
||||
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")
|
||||
@@ -52,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
|
||||
}
|
||||
|
||||
@@ -89,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
|
||||
}
|
||||
|
||||
@@ -108,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
|
||||
}
|
||||
|
||||
@@ -126,19 +174,38 @@ var encode = &cli.Command{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to nprofile code",
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
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 = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,18 +226,45 @@ var encode = &cli.Command{
|
||||
Aliases: []string{"a"},
|
||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
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 = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,43 +281,85 @@ 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",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for 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
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
146
event.go
146
event.go
@@ -24,7 +24,7 @@ const (
|
||||
CATEGORY_EXTRAS = "EXTRAS"
|
||||
)
|
||||
|
||||
var event = &cli.Command{
|
||||
var eventCmd = &cli.Command{
|
||||
Name: "event",
|
||||
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
|
||||
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
|
||||
@@ -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,8 @@ example:
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// try to connect to the relays here
|
||||
var relays []*nostr.Relay
|
||||
relayUrls := c.Args().Slice()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,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"`)
|
||||
@@ -211,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...)
|
||||
}
|
||||
@@ -220,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 {
|
||||
@@ -243,6 +291,9 @@ example:
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
evt.PubKey = authorPubKey
|
||||
}
|
||||
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
@@ -252,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
|
||||
}
|
||||
|
||||
@@ -267,7 +318,7 @@ example:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||
}
|
||||
|
||||
@@ -278,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")
|
||||
@@ -303,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") {
|
||||
@@ -339,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, " ") + " ]? ") {
|
||||
|
||||
40
fetch.go
40
fetch.go
@@ -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)
|
||||
|
||||
31
filter.go
31
filter.go
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var filter = &cli.Command{
|
||||
var filterCmd = &cli.Command{
|
||||
Name: "filter",
|
||||
Usage: "applies an event filter to an event to see if it matches.",
|
||||
Description: `
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
118
flags.go
118
flags.go
@@ -11,6 +11,62 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type (
|
||||
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
|
||||
)
|
||||
|
||||
type boolIntValue struct {
|
||||
int int
|
||||
defaultWhenSet int
|
||||
hasDefault bool
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
|
||||
|
||||
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
|
||||
*p = val
|
||||
|
||||
return &boolIntValue{
|
||||
defaultWhenSet: val,
|
||||
hasDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t boolIntValue) IsBoolFlag() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t boolIntValue) ToString(b int) string { return "<<>>" }
|
||||
|
||||
func (t *boolIntValue) Set(value string) error {
|
||||
t.hasBeenSet = true
|
||||
if value == "true" {
|
||||
if t.hasDefault {
|
||||
t.int = t.defaultWhenSet
|
||||
} else {
|
||||
t.int = 1
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
var err error
|
||||
t.int, err = strconv.Atoi(value)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
|
||||
func (t *boolIntValue) Value() int { return t.int }
|
||||
func (t *boolIntValue) Get() any { return t.int }
|
||||
|
||||
func getBoolInt(cmd *cli.Command, name string) int {
|
||||
return cmd.Value(name).(int)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||
|
||||
type naturalTimeValue struct {
|
||||
@@ -49,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 {
|
||||
@@ -127,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]
|
||||
)
|
||||
|
||||
2
fs.go
2
fs.go
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !openbsd
|
||||
//go:build !windows && !openbsd && !cgofuse
|
||||
|
||||
package main
|
||||
|
||||
|
||||
118
fs_cgo.go
Normal file
118
fs_cgo.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build cgofuse && !windows && !openbsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: 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
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build windows || openbsd
|
||||
//go:build openbsd
|
||||
|
||||
package main
|
||||
|
||||
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
|
||||
Description: `doesn't work on Windows and OpenBSD.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return fmt.Errorf("this doesn't work on Windows and OpenBSD.")
|
||||
return fmt.Errorf("this doesn't work on OpenBSD.")
|
||||
},
|
||||
}
|
||||
|
||||
140
fs_windows.go
Normal file
140
fs_windows.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: 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 - Windows/WinFsp version
|
||||
// based on rclone cmount implementation
|
||||
mountArgs := []string{
|
||||
"-o", "uid=-1",
|
||||
"-o", "gid=-1",
|
||||
"--FileSystemName=nak",
|
||||
}
|
||||
|
||||
// check if mountpoint is a drive letter or directory
|
||||
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
|
||||
|
||||
if !isDriveLetter {
|
||||
// winFsp primarily supports drive letters on Windows
|
||||
// directory mounting may not work reliably
|
||||
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
|
||||
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
|
||||
|
||||
// for directory mounts, follow rclone's approach:
|
||||
// 1. check that mountpoint doesn't already exist
|
||||
if _, err := os.Stat(mountpoint); err == nil {
|
||||
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to check mountpoint: %w", err)
|
||||
}
|
||||
|
||||
// 2. check that parent directory exists
|
||||
parent := filepath.Join(mountpoint, "..")
|
||||
if _, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
|
||||
}
|
||||
return fmt.Errorf("failed to check parent directory: %w", err)
|
||||
}
|
||||
|
||||
// 3. use network mode for directory mounts
|
||||
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
|
||||
}
|
||||
|
||||
if isVerbose {
|
||||
mountArgs = append(mountArgs, "-o", "debug")
|
||||
}
|
||||
mountArgs = append(mountArgs, mountpoint)
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
if !host.Mount("", mountArgs) {
|
||||
return fmt.Errorf("failed to mount filesystem")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
252
gift.go
252
gift.go
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
@@ -16,19 +20,29 @@ var gift = &cli.Command{
|
||||
Name: "gift",
|
||||
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
||||
Description: `example:
|
||||
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>`,
|
||||
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>
|
||||
|
||||
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: defaultKeyFlags,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "wrap",
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
Required: true,
|
||||
},
|
||||
),
|
||||
&cli.BoolFlag{
|
||||
Name: "use-our-identity-key",
|
||||
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "use-their-identity-key",
|
||||
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
|
||||
},
|
||||
},
|
||||
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
|
||||
Description: `example:
|
||||
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
|
||||
@@ -38,14 +52,46 @@ var gift = &cli.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
recipient := getPubKey(c, "recipient-pubkey")
|
||||
|
||||
// get sender pubkey
|
||||
// get sender pubkey (ourselves)
|
||||
sender, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
||||
}
|
||||
|
||||
var using bool
|
||||
|
||||
var cipher nostr.Cipher = kr
|
||||
// use decoupled key if it exists
|
||||
using = false
|
||||
if !c.Bool("use-our-identity-key") {
|
||||
configPath := c.String("config-path")
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
|
||||
}
|
||||
cipher = keyer.NewPlainKeySigner(eSec)
|
||||
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
using = true
|
||||
}
|
||||
}
|
||||
if !using {
|
||||
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
|
||||
}
|
||||
|
||||
recipient := getPubKey(c, "recipient-pubkey")
|
||||
using = false
|
||||
if !c.Bool("use-their-identity-key") {
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
|
||||
recipient = theirEPub
|
||||
using = true
|
||||
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
|
||||
}
|
||||
}
|
||||
if !using {
|
||||
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
|
||||
}
|
||||
|
||||
// read event from stdin
|
||||
for eventJSON := range getJsonsOrBlank() {
|
||||
if eventJSON == "{}" {
|
||||
@@ -65,7 +111,7 @@ var gift = &cli.Command{
|
||||
|
||||
// create seal
|
||||
rumorJSON, _ := easyjson.Marshal(rumor)
|
||||
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
|
||||
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
||||
}
|
||||
@@ -114,22 +160,30 @@ var gift = &cli.Command{
|
||||
Name: "unwrap",
|
||||
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
||||
Description: `example:
|
||||
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`,
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "sender-pubkey",
|
||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
||||
Required: true,
|
||||
},
|
||||
),
|
||||
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sender := getPubKey(c, "sender-pubkey")
|
||||
// get receiver public key (ourselves)
|
||||
receiver, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ciphers := []nostr.Cipher{kr}
|
||||
// use decoupled key if it exists
|
||||
configPath := c.String("config-path")
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
}
|
||||
ciphers = append(ciphers, kr)
|
||||
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||
}
|
||||
|
||||
// read gift-wrapped event from stdin
|
||||
for wrapJSON := range getJsonsOrBlank() {
|
||||
@@ -146,36 +200,93 @@ var gift = &cli.Command{
|
||||
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
||||
}
|
||||
|
||||
ephemeralPubkey := wrap.PubKey
|
||||
|
||||
// decrypt seal
|
||||
sealJSON, err := kr.Decrypt(ctx, wrap.Content, ephemeralPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||
}
|
||||
|
||||
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
|
||||
var cipher nostr.Cipher
|
||||
var seal nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
|
||||
return fmt.Errorf("invalid seal JSON: %w", err)
|
||||
|
||||
// try both the receiver identity key and decoupled key
|
||||
err = nil
|
||||
for c, potentialCipher := range ciphers {
|
||||
switch c {
|
||||
case 0:
|
||||
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
case 1:
|
||||
if eSec.Public() == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
}
|
||||
|
||||
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||
if thisErr != nil {
|
||||
err = thisErr
|
||||
continue
|
||||
}
|
||||
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
|
||||
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
|
||||
cipher = potentialCipher
|
||||
break
|
||||
}
|
||||
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
|
||||
// if both ciphers failed above we'll reach here
|
||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||
}
|
||||
|
||||
if seal.Kind != 13 {
|
||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||
}
|
||||
|
||||
// decrypt rumor
|
||||
rumorJSON, err := kr.Decrypt(ctx, seal.Content, sender)
|
||||
if err != nil {
|
||||
if !seal.VerifySignature() {
|
||||
return fmt.Errorf("seal signature is invalid")
|
||||
}
|
||||
|
||||
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
|
||||
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
|
||||
}
|
||||
|
||||
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
|
||||
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
|
||||
var rumor nostr.Event
|
||||
err = nil
|
||||
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
|
||||
if senderEncryptionPublicKey == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
|
||||
switch s {
|
||||
case 0:
|
||||
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
case 1:
|
||||
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
}
|
||||
|
||||
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
|
||||
if thisErr != nil {
|
||||
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
|
||||
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
|
||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
||||
}
|
||||
|
||||
var rumor nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(rumorJSON), &rumor); err != nil {
|
||||
return fmt.Errorf("invalid rumor JSON: %w", err)
|
||||
}
|
||||
rumor.PubKey = seal.PubKey
|
||||
rumor.ID = rumor.GetID()
|
||||
|
||||
// output the unwrapped event (rumor)
|
||||
stdout(rumorJSON)
|
||||
stdout(rumor.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -190,3 +301,72 @@ func randomNow() nostr.Timestamp {
|
||||
randomOffset := rand.Int63n(twoDays)
|
||||
return nostr.Timestamp(now - randomOffset)
|
||||
}
|
||||
|
||||
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
|
||||
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||
if ok {
|
||||
var ePub nostr.PubKey
|
||||
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||
}
|
||||
|
||||
// check if we have the key
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
|
||||
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||
eSec, err := nostr.SecretKeyFromHex(string(data))
|
||||
if err != nil {
|
||||
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||
}
|
||||
return eSec, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return [32]byte{}, false, nil
|
||||
}
|
||||
|
||||
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
|
||||
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||
|
||||
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{10044},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||
|
||||
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||
if ok {
|
||||
var ePub nostr.PubKey
|
||||
|
||||
// get the pub from the tag
|
||||
for _, tag := range keyAnnouncementEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return nostr.ZeroPK, false
|
||||
}
|
||||
|
||||
return ePub, true
|
||||
}
|
||||
|
||||
return nostr.ZeroPK, false
|
||||
}
|
||||
|
||||
23
go.mod
23
go.mod
@@ -3,8 +3,7 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.1
|
||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157
|
||||
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,26 +11,33 @@ require (
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/hanwen/go-fuse/v2 v2.7.2
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/liamg/magic v0.0.1
|
||||
github.com/mailru/easyjson v0.9.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
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||
github.com/winfsp/cgofuse v1.6.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.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
|
||||
github.com/PowerDNS/lmdb-go v1.9.3 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -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,15 +72,15 @@ 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
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.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
|
||||
@@ -96,10 +104,9 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
go.etcd.io/bbolt v1.4.2 // 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
|
||||
|
||||
40
go.sum
40
go.sum
@@ -1,7 +1,7 @@
|
||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157 h1:14yLsO2HwpS2CLIKFvLMDp8tVEDahwdC8OeG6NGaL+M=
|
||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157/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=
|
||||
@@ -83,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=
|
||||
@@ -144,13 +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.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
|
||||
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
|
||||
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=
|
||||
@@ -190,18 +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.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
||||
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=
|
||||
@@ -269,6 +277,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
|
||||
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
@@ -281,8 +291,6 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
@@ -322,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=
|
||||
|
||||
181
helpers.go
181
helpers.go
@@ -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"
|
||||
)
|
||||
@@ -46,8 +47,14 @@ var (
|
||||
)
|
||||
|
||||
func isPiped() bool {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
return stat.Mode()&os.ModeCharDevice == 0
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mode := stat.Mode()
|
||||
is := mode&os.ModeCharDevice == 0
|
||||
return is
|
||||
}
|
||||
|
||||
func getJsonsOrBlank() iter.Seq[string] {
|
||||
@@ -55,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
|
||||
@@ -69,6 +77,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
finalJsonErr = nil
|
||||
|
||||
if !yield(stdinEvent) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -76,8 +85,14 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
return true
|
||||
})
|
||||
|
||||
if !hasStdin && !isPiped() {
|
||||
yield("{}")
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
if !yield("{}") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if finalJsonErr != nil {
|
||||
@@ -88,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,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() {
|
||||
@@ -224,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)
|
||||
@@ -255,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)
|
||||
@@ -409,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()
|
||||
@@ -466,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)
|
||||
@@ -520,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()
|
||||
}
|
||||
@@ -529,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,
|
||||
|
||||
@@ -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
73
install.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
# detect OS
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*) echo "linux";;
|
||||
Darwin*) echo "darwin";;
|
||||
FreeBSD*) echo "freebsd";;
|
||||
MINGW*|MSYS*|CYGWIN*) echo "windows";;
|
||||
*)
|
||||
echo "error: unsupported OS $(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# detect architecture
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64";;
|
||||
aarch64|arm64) echo "arm64";;
|
||||
riscv64) echo "riscv64";;
|
||||
*)
|
||||
echo "error: unsupported architecture $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# set install directory
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
# detect platform
|
||||
OS=$(detect_os)
|
||||
ARCH=$(detect_arch)
|
||||
|
||||
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
|
||||
|
||||
# check if curl is available
|
||||
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
|
||||
|
||||
# get latest release tag
|
||||
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
|
||||
TAG="${RELEASE_INFO#*\"tag_name\"}"
|
||||
TAG="${TAG#*\"}"
|
||||
TAG="${TAG%%\"*}"
|
||||
|
||||
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
|
||||
|
||||
# construct download URL
|
||||
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
|
||||
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
|
||||
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
|
||||
|
||||
# create install directory and download
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
TARGET_PATH="$INSTALL_DIR/nak"
|
||||
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
|
||||
|
||||
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
|
||||
chmod +x "$TARGET_PATH"
|
||||
echo "installed nak $TAG to $TARGET_PATH"
|
||||
|
||||
# check if install dir is in PATH
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*) ;;
|
||||
*) echo "note: add $INSTALL_DIR to your PATH" ;;
|
||||
esac
|
||||
else
|
||||
echo "error: download failed from $DOWNLOAD_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
83
jq.go
Normal file
83
jq.go
Normal 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
|
||||
}
|
||||
}
|
||||
116
key.go
116
key.go
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,12 +368,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
|
||||
continue
|
||||
}
|
||||
sk = data.(nostr.SecretKey)
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
} else {
|
||||
var err error
|
||||
sk, err = nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ch <- sk
|
||||
|
||||
45
lmdb.go
Normal file
45
lmdb.go
Normal file
@@ -0,0 +1,45 @@
|
||||
//go:build linux && !riscv64 && !arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fiatjaf.com/nostr/eventstore/lmdb"
|
||||
"fiatjaf.com/nostr/eventstore/nullstore"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"fiatjaf.com/nostr/sdk/hints/lmdbh"
|
||||
lmdbkv "fiatjaf.com/nostr/sdk/kvstore/lmdb"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
configPath := c.String("config-path")
|
||||
if configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
_, err := lmdbh.NewLMDBHints(hintsPath)
|
||||
if err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
45
main.go
45
main.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
@@ -26,9 +25,9 @@ var app = &cli.Command{
|
||||
Usage: "the nostr army knife command-line tool",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
event,
|
||||
eventCmd,
|
||||
req,
|
||||
filter,
|
||||
filterCmd,
|
||||
fetch,
|
||||
count,
|
||||
decode,
|
||||
@@ -40,18 +39,24 @@ var app = &cli.Command{
|
||||
bunker,
|
||||
serve,
|
||||
blossomCmd,
|
||||
dekey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
gift,
|
||||
outbox,
|
||||
outboxCmd,
|
||||
wallet,
|
||||
mcpServer,
|
||||
curl,
|
||||
fsCmd,
|
||||
publish,
|
||||
nsite,
|
||||
git,
|
||||
group,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
profile,
|
||||
validateCmd,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
@@ -62,7 +67,7 @@ var app = &cli.Command{
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config/nak")
|
||||
} else {
|
||||
return filepath.Join("/dev/null")
|
||||
return ""
|
||||
}
|
||||
})(),
|
||||
},
|
||||
@@ -98,17 +103,13 @@ var app = &cli.Command{
|
||||
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||
sys = sdk.NewSystem()
|
||||
|
||||
if err := initializeOutboxHintsDB(c, sys); err != nil {
|
||||
return ctx, fmt.Errorf("failed to initialize outbox hints: %w", err)
|
||||
}
|
||||
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
|
||||
},
|
||||
@@ -119,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() {
|
||||
@@ -127,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)
|
||||
}
|
||||
@@ -137,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
149
mcp.go
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
11
non_lmdb.go
Normal file
11
non_lmdb.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux || riscv64 || arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
}
|
||||
1177
nostrfs_cgo/root.go
Normal file
1177
nostrfs_cgo/root.go
Normal file
File diff suppressed because it is too large
Load Diff
309
nsite.go
Normal file
309
nsite.go
Normal 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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
61
outbox.go
61
outbox.go
@@ -3,80 +3,21 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"fiatjaf.com/nostr/sdk/hints/bbolth"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
hintsFilePath string
|
||||
hintsFileExists bool
|
||||
)
|
||||
|
||||
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
|
||||
configPath := c.String("config-path")
|
||||
if configPath != "" {
|
||||
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
|
||||
}
|
||||
if hintsFilePath != "" {
|
||||
if _, err := os.Stat(hintsFilePath); err == nil {
|
||||
hintsFileExists = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hintsFileExists && hintsFilePath != "" {
|
||||
hintsdb, err := bbolth.NewBoltHints(hintsFilePath)
|
||||
if err == nil {
|
||||
sys.Hints = hintsdb
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var outbox = &cli.Command{
|
||||
var outboxCmd = &cli.Command{
|
||||
Name: "outbox",
|
||||
Usage: "manage outbox relay hints database",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "init",
|
||||
Usage: "initialize the outbox hints database",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if hintsFileExists {
|
||||
return nil
|
||||
}
|
||||
if hintsFilePath == "" {
|
||||
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
|
||||
}
|
||||
|
||||
os.MkdirAll(hintsFilePath, 0755)
|
||||
_, err := bbolth.NewBoltHints(hintsFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bolt hints db at '%s': %w", hintsFilePath, err)
|
||||
}
|
||||
|
||||
log("initialized hints database at %s\n", hintsFilePath)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "list outbox relays for a given pubkey",
|
||||
ArgsUsage: "<pubkey>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if !hintsFileExists {
|
||||
log(color.YellowString("running with temporary fragile data.\n"))
|
||||
log(color.YellowString("call `nak outbox init` to setup persistence.\n"))
|
||||
}
|
||||
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("expected exactly one argument (pubkey)")
|
||||
}
|
||||
|
||||
102
profile.go
Normal file
102
profile.go
Normal 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
|
||||
},
|
||||
}
|
||||
15
publish.go
15
publish.go
@@ -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 {
|
||||
|
||||
2
relay.go
2
relay.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
445
req.go
445
req.go
@@ -9,6 +9,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/eventstore"
|
||||
@@ -43,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",
|
||||
@@ -59,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{
|
||||
@@ -77,11 +86,6 @@ example:
|
||||
Name: "paginate-interval",
|
||||
Usage: "time between queries when using --paginate",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "paginate-global-limit",
|
||||
Usage: "global limit at which --paginate should stop",
|
||||
DefaultText: "uses the value given by --limit/-l or infinite",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bare",
|
||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||
@@ -96,10 +100,18 @@ example:
|
||||
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "spell",
|
||||
Usage: "output a spell event (kind 777) instead of a filter",
|
||||
},
|
||||
)...,
|
||||
),
|
||||
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") {
|
||||
@@ -115,7 +127,24 @@ example:
|
||||
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
||||
}
|
||||
|
||||
if c.Bool("bare") && c.Bool("spell") {
|
||||
return fmt.Errorf("incompatible flags --bare and --spell")
|
||||
}
|
||||
|
||||
relayUrls := c.Args().Slice()
|
||||
|
||||
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
|
||||
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
|
||||
@@ -123,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 {
|
||||
@@ -186,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 {
|
||||
@@ -197,6 +229,7 @@ example:
|
||||
|
||||
target := PrintingQuerierPublisher{
|
||||
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
|
||||
jq: jq,
|
||||
}
|
||||
|
||||
var source nostr.Querier = nil
|
||||
@@ -226,100 +259,31 @@ example:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var results chan nostr.RelayEvent
|
||||
opts := nostr.SubscriptionOptions{
|
||||
Label: "nak-req",
|
||||
}
|
||||
|
||||
if c.Bool("paginate") {
|
||||
paginator := sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
|
||||
results = paginator(ctx, relayUrls, filter, opts)
|
||||
} else if c.Bool("outbox") {
|
||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||
|
||||
// hardcoded relays, if any
|
||||
for _, relayUrl := range relayUrls {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: relayUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(c.Uint("outbox-relays-per-pubkey"))
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already hardcoded, 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
if c.Bool("stream") {
|
||||
results = sys.Pool.BatchedSubscribeMany(ctx, defs, opts)
|
||||
} else {
|
||||
results = sys.Pool.BatchedQueryMany(ctx, defs, opts)
|
||||
}
|
||||
} else {
|
||||
if c.Bool("stream") {
|
||||
results = sys.Pool.SubscribeMany(ctx, relayUrls, filter, opts)
|
||||
} else {
|
||||
results = sys.Pool.FetchMany(ctx, relayUrls, filter, opts)
|
||||
}
|
||||
}
|
||||
|
||||
for ie := range results {
|
||||
stdout(ie.Event)
|
||||
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
// no relays given, will just print the filter or spell
|
||||
var result string
|
||||
if c.Bool("bare") {
|
||||
if c.Bool("spell") {
|
||||
// output a spell event instead of a filter
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spellEvent := createSpellEvent(ctx, filter, kr)
|
||||
j, _ := json.Marshal(spellEvent)
|
||||
result = string(j)
|
||||
} else if c.Bool("bare") {
|
||||
// bare filter output
|
||||
result = filter.String()
|
||||
} else {
|
||||
// normal filter
|
||||
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
|
||||
result = string(j)
|
||||
}
|
||||
|
||||
}
|
||||
stdout(result)
|
||||
}
|
||||
}
|
||||
@@ -329,8 +293,205 @@ example:
|
||||
},
|
||||
}
|
||||
|
||||
func performReq(
|
||||
ctx context.Context,
|
||||
filter nostr.Filter,
|
||||
relayUrls []string,
|
||||
stream bool,
|
||||
outbox bool,
|
||||
outboxRelaysPerPubKey uint64,
|
||||
paginate bool,
|
||||
paginateInterval time.Duration,
|
||||
label string,
|
||||
jq jqProcessor,
|
||||
) error {
|
||||
var results chan nostr.RelayEvent
|
||||
var closeds chan nostr.RelayClosed
|
||||
|
||||
opts := nostr.SubscriptionOptions{
|
||||
Label: label,
|
||||
}
|
||||
|
||||
if paginate {
|
||||
paginator := sys.Pool.PaginatorWithInterval(paginateInterval)
|
||||
results = paginator(ctx, relayUrls, filter, opts)
|
||||
} else if outbox {
|
||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||
|
||||
pTagPubkeys := make([]nostr.PubKey, 0, 4)
|
||||
if len(filter.Authors) == 0 && filter.Tags != nil {
|
||||
seen := make(map[nostr.PubKey]struct{})
|
||||
pTags := append([]string{}, filter.Tags["p"]...)
|
||||
pTags = append(pTags, filter.Tags["P"]...)
|
||||
for _, value := range pTags {
|
||||
pubkey, err := parsePubKey(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[pubkey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pubkey] = struct{}{}
|
||||
pTagPubkeys = append(pTagPubkeys, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, relayUrl := range relayUrls {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: relayUrl,
|
||||
})
|
||||
}
|
||||
|
||||
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
|
||||
// relays for p tags when no authors are given
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
|
||||
for _, pubkey := range pTagPubkeys {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
} else {
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
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()
|
||||
}
|
||||
|
||||
if stream {
|
||||
logverbose("running subscription with %d directed filters...\n", len(defs))
|
||||
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
|
||||
} else {
|
||||
logverbose("running query with %d directed filters...\n", len(defs))
|
||||
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
|
||||
}
|
||||
} else {
|
||||
if stream {
|
||||
logverbose("running subscription to %d relays...\n", len(relayUrls))
|
||||
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||
} else {
|
||||
logverbose("running query to %d relays...\n", len(relayUrls))
|
||||
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||
}
|
||||
}
|
||||
|
||||
readevents:
|
||||
for {
|
||||
select {
|
||||
case ie, stillOpen := <-results:
|
||||
if !stillOpen {
|
||||
break readevents
|
||||
}
|
||||
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
v, matches, err := jq(ie.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out, _ = 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",
|
||||
@@ -369,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"},
|
||||
@@ -394,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...)
|
||||
}
|
||||
@@ -407,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 {
|
||||
@@ -431,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") {
|
||||
@@ -455,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
|
||||
|
||||
171
serve.go
171
serve.go
@@ -6,9 +6,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -51,6 +54,12 @@ var serve = &cli.Command{
|
||||
Name: "grasp",
|
||||
Usage: "enable grasp server",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "grasp-path",
|
||||
Usage: "where to store the repositories",
|
||||
TakesFile: true,
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "blossom",
|
||||
Usage: "enable blossom server",
|
||||
@@ -99,89 +108,11 @@ var serve = &cli.Command{
|
||||
rl.Negentropy = true
|
||||
}
|
||||
|
||||
started := make(chan bool)
|
||||
exited := make(chan error)
|
||||
|
||||
hostname := c.String("hostname")
|
||||
port := int(c.Uint("port"))
|
||||
|
||||
var printStatus func()
|
||||
|
||||
if c.Bool("blossom") {
|
||||
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
|
||||
bs.Store = blossom.NewMemoryBlobIndex()
|
||||
|
||||
blobStore = xsync.NewMapOf[string, []byte]()
|
||||
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
|
||||
blobStore.Store(sha256+ext, body)
|
||||
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
|
||||
if body, ok := blobStore.Load(sha256 + ext); ok {
|
||||
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
|
||||
printStatus()
|
||||
return bytes.NewReader(body), nil, nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
|
||||
blobStore.Delete(sha256 + ext)
|
||||
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bool("grasp") {
|
||||
var err error
|
||||
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create grasp repos directory: %w", err)
|
||||
}
|
||||
g := grasp.New(rl, repoDir)
|
||||
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.YellowString("git write"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := rl.Start(hostname, port, started)
|
||||
exited <- err
|
||||
}()
|
||||
|
||||
// relay logging
|
||||
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
negentropy := ""
|
||||
if khatru.IsNegentropySession(ctx) {
|
||||
negentropy = color.HiBlueString("negentropy ")
|
||||
}
|
||||
|
||||
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
@@ -192,6 +123,7 @@ var serve = &cli.Command{
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
var printStatus func()
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
@@ -236,7 +168,88 @@ var serve = &cli.Command{
|
||||
})
|
||||
}
|
||||
|
||||
<-started
|
||||
if c.Bool("blossom") {
|
||||
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
|
||||
bs.Store = blossom.NewMemoryBlobIndex()
|
||||
|
||||
blobStore = xsync.NewMapOf[string, []byte]()
|
||||
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
|
||||
blobStore.Store(sha256+ext, body)
|
||||
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
|
||||
if body, ok := blobStore.Load(sha256 + ext); ok {
|
||||
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
|
||||
printStatus()
|
||||
return bytes.NewReader(body), nil, nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
|
||||
blobStore.Delete(sha256 + ext)
|
||||
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
|
||||
printStatus()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bool("grasp") {
|
||||
repoDir = c.String("grasp-path")
|
||||
if repoDir == "" {
|
||||
var err error
|
||||
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create grasp repos directory: %w", err)
|
||||
}
|
||||
}
|
||||
g := grasp.New(rl, repoDir)
|
||||
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
||||
log(" got %s %s %s\n", color.YellowString("git write"), pubkey.Hex(), repo)
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
exited <- http.Serve(ln, rl)
|
||||
}()
|
||||
|
||||
// relay logging
|
||||
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
negentropy := ""
|
||||
if khatru.IsNegentropySession(ctx) {
|
||||
negentropy = color.HiBlueString("negentropy ")
|
||||
}
|
||||
|
||||
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
|
||||
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
|
||||
printStatus()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
536
spell.go
Normal file
536
spell.go
Normal file
@@ -0,0 +1,536 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/sdk/hints"
|
||||
"github.com/fatih/color"
|
||||
"github.com/markusmobius/go-dateparser"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var spell = &cli.Command{
|
||||
Name: "spell",
|
||||
Usage: "downloads a spell event and executes its REQ request",
|
||||
ArgsUsage: "[nevent_code]",
|
||||
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
|
||||
Flags: append(defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "pub",
|
||||
Usage: "public key to run spells in the context of (if you don't want to pass a --sec)",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "outbox-relays-per-pubkey",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "number of outbox relays to use for each pubkey",
|
||||
Value: 3,
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
configPath := c.String("config-path")
|
||||
os.MkdirAll(filepath.Join(configPath, "spells"), 0755)
|
||||
|
||||
// load history from file
|
||||
var history []SpellHistoryEntry
|
||||
historyPath := filepath.Join(configPath, "spells/history")
|
||||
file, err := os.Open(historyPath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
var entry SpellHistoryEntry
|
||||
if err := json.Unmarshal([]byte(scanner.Text()), &entry); err != nil {
|
||||
continue // skip invalid entries
|
||||
}
|
||||
history = append(history, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Args().Len() == 0 {
|
||||
// check if we have input from stdin
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
if stdinEvent == "{}" {
|
||||
break
|
||||
}
|
||||
|
||||
var spell nostr.Event
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &spell); err != nil {
|
||||
return fmt.Errorf("failed to parse spell event from stdin: %w", err)
|
||||
}
|
||||
if spell.Kind != 777 {
|
||||
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
|
||||
}
|
||||
|
||||
return runSpell(ctx, c, historyPath, history, nostr.EventPointer{ID: spell.ID}, spell)
|
||||
}
|
||||
|
||||
// no stdin input, show recent spells
|
||||
log("recent spells:\n")
|
||||
for i, entry := range history {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
|
||||
displayName := entry.Name
|
||||
if displayName == "" {
|
||||
displayName = clampWithEllipsis(entry.Content, 28)
|
||||
}
|
||||
if displayName != "" {
|
||||
displayName = color.HiMagentaString(displayName) + ": "
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(entry.Content, 50)
|
||||
|
||||
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
|
||||
stdout(fmt.Sprintf(" %s %s%s - %s",
|
||||
color.BlueString(entry.Identifier),
|
||||
displayName,
|
||||
color.YellowString(lastUsed),
|
||||
desc,
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decode nevent to get the spell event
|
||||
var pointer nostr.EventPointer
|
||||
identifier := c.Args().First()
|
||||
prefix, value, err := nip19.Decode(identifier)
|
||||
if err == nil {
|
||||
if prefix != "nevent" {
|
||||
return fmt.Errorf("expected nevent code, got %s", prefix)
|
||||
}
|
||||
pointer = value.(nostr.EventPointer)
|
||||
} else {
|
||||
// search our history
|
||||
for _, entry := range history {
|
||||
if entry.Identifier == identifier || entry.Name == identifier {
|
||||
pointer = entry.Pointer
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pointer.ID == nostr.ZeroID {
|
||||
return fmt.Errorf("invalid spell reference")
|
||||
}
|
||||
|
||||
// first try to fetch spell from sys.Store
|
||||
var spell nostr.Event
|
||||
found := false
|
||||
for evt := range sys.Store.QueryEvents(nostr.Filter{IDs: []nostr.ID{pointer.ID}}, 1) {
|
||||
spell = evt
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
var relays []string
|
||||
if !found {
|
||||
// if not found in store, fetch from external relays
|
||||
relays = pointer.Relays
|
||||
if pointer.Author != nostr.ZeroPK {
|
||||
for _, url := range relays {
|
||||
sys.Hints.Save(pointer.Author, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
|
||||
}
|
||||
relays = append(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
|
||||
}
|
||||
result := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}},
|
||||
nostr.SubscriptionOptions{Label: "nak-spell-f"})
|
||||
if result == nil {
|
||||
return fmt.Errorf("spell event not found")
|
||||
}
|
||||
spell = result.Event
|
||||
}
|
||||
if spell.Kind != 777 {
|
||||
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
|
||||
}
|
||||
|
||||
return runSpell(ctx, c, historyPath, history, pointer, spell)
|
||||
},
|
||||
}
|
||||
|
||||
func runSpell(
|
||||
ctx context.Context,
|
||||
c *cli.Command,
|
||||
historyPath string,
|
||||
history []SpellHistoryEntry,
|
||||
pointer nostr.EventPointer,
|
||||
spell nostr.Event,
|
||||
) error {
|
||||
// parse spell tags to build REQ filter
|
||||
spellFilter, err := buildSpellReq(ctx, c, spell.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse spell tags: %w", err)
|
||||
}
|
||||
|
||||
// determine relays to query
|
||||
var spellRelays []string
|
||||
var outbox bool
|
||||
relaysTag := spell.Tags.Find("relays")
|
||||
if relaysTag == nil {
|
||||
// if this tag doesn't exist assume $outbox
|
||||
relaysTag = nostr.Tag{"relays", "$outbox"}
|
||||
}
|
||||
for i := 1; i < len(relaysTag); i++ {
|
||||
switch relaysTag[i] {
|
||||
case "$outbox":
|
||||
outbox = true
|
||||
default:
|
||||
spellRelays = append(spellRelays, relaysTag[i])
|
||||
}
|
||||
}
|
||||
|
||||
stream := !spell.Tags.Has("close-on-eose")
|
||||
|
||||
// fill in the author if we didn't have it
|
||||
pointer.Author = spell.PubKey
|
||||
|
||||
// save spell to sys.Store
|
||||
if err := sys.Store.SaveEvent(spell); err != nil {
|
||||
logverbose("failed to save spell to store: %v\n", err)
|
||||
}
|
||||
|
||||
// add to history before execution
|
||||
{
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
nameTag := spell.Tags.Find("name")
|
||||
var name string
|
||||
if nameTag != nil {
|
||||
name = nameTag[1]
|
||||
}
|
||||
if len(history) > 100 {
|
||||
history = history[:100]
|
||||
}
|
||||
// write back to file
|
||||
file, err := os.Create(historyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(SpellHistoryEntry{
|
||||
Identifier: identifier,
|
||||
Name: name,
|
||||
Content: spell.Content,
|
||||
LastUsed: time.Now(),
|
||||
Pointer: pointer,
|
||||
})
|
||||
file.Write(data)
|
||||
file.Write([]byte{'\n'})
|
||||
for i, entry := range history {
|
||||
if entry.Identifier == identifier {
|
||||
continue
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(entry)
|
||||
file.Write(data)
|
||||
file.Write([]byte{'\n'})
|
||||
|
||||
// limit history size (keep last 100)
|
||||
if i == 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
file.Close()
|
||||
|
||||
logverbose("executing %s: %s relays=%v outbox=%v stream=%v\n",
|
||||
identifier, spellFilter, spellRelays, outbox, stream)
|
||||
}
|
||||
|
||||
// execute
|
||||
logSpellDetails(spell)
|
||||
if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSpellReq(ctx context.Context, c *cli.Command, tags nostr.Tags) (nostr.Filter, error) {
|
||||
filter := nostr.Filter{}
|
||||
|
||||
getMe := func() (nostr.PubKey, error) {
|
||||
if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") {
|
||||
return parsePubKey(c.String("pub"))
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, fmt.Errorf("failed to get keyer: %w", err)
|
||||
}
|
||||
|
||||
pubkey, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, fmt.Errorf("failed to get public key from keyer: %w", err)
|
||||
}
|
||||
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if len(tag) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch tag[0] {
|
||||
case "cmd":
|
||||
if len(tag) < 2 || tag[1] != "REQ" {
|
||||
return nostr.Filter{}, fmt.Errorf("only REQ commands are supported")
|
||||
}
|
||||
|
||||
case "k":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
if kind, err := strconv.Atoi(tag[i]); err == nil {
|
||||
filter.Kinds = append(filter.Kinds, nostr.Kind(kind))
|
||||
}
|
||||
}
|
||||
|
||||
case "authors":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
switch tag[i] {
|
||||
case "$me":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
filter.Authors = append(filter.Authors, me)
|
||||
case "$contacts":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
for _, f := range sys.FetchFollowList(ctx, me).Items {
|
||||
filter.Authors = append(filter.Authors, f.Pubkey)
|
||||
}
|
||||
default:
|
||||
pubkey, err := nostr.PubKeyFromHex(tag[i])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid pubkey '%s' in 'authors': %w", tag[i], err)
|
||||
}
|
||||
filter.Authors = append(filter.Authors, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
case "ids":
|
||||
for i := 1; i < len(tag); i++ {
|
||||
id, err := nostr.IDFromHex(tag[i])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid id '%s' in 'authors': %w", tag[i], err)
|
||||
}
|
||||
filter.IDs = append(filter.IDs, id)
|
||||
}
|
||||
|
||||
case "tag":
|
||||
if len(tag) < 3 {
|
||||
continue
|
||||
}
|
||||
tagName := tag[1]
|
||||
if filter.Tags == nil {
|
||||
filter.Tags = make(nostr.TagMap)
|
||||
}
|
||||
for i := 2; i < len(tag); i++ {
|
||||
switch tag[i] {
|
||||
case "$me":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], me.Hex())
|
||||
case "$contacts":
|
||||
me, err := getMe()
|
||||
if err != nil {
|
||||
return nostr.Filter{}, err
|
||||
}
|
||||
for _, f := range sys.FetchFollowList(ctx, me).Items {
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], f.Pubkey.Hex())
|
||||
}
|
||||
default:
|
||||
filter.Tags[tagName] = append(filter.Tags[tagName], tag[i])
|
||||
}
|
||||
}
|
||||
|
||||
case "limit":
|
||||
if len(tag) >= 2 {
|
||||
if limit, err := strconv.Atoi(tag[1]); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
case "since":
|
||||
if len(tag) >= 2 {
|
||||
date, err := dateparser.Parse(&dateparser.Configuration{
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, tag[1])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
|
||||
}
|
||||
filter.Since = nostr.Timestamp(date.Time.Unix())
|
||||
}
|
||||
|
||||
case "until":
|
||||
if len(tag) >= 2 {
|
||||
date, err := dateparser.Parse(&dateparser.Configuration{
|
||||
DefaultTimezone: time.Local,
|
||||
CurrentTime: time.Now(),
|
||||
}, tag[1])
|
||||
if err != nil {
|
||||
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
|
||||
}
|
||||
filter.Until = nostr.Timestamp(date.Time.Unix())
|
||||
}
|
||||
|
||||
case "search":
|
||||
if len(tag) >= 2 {
|
||||
filter.Search = tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func parseRelativeTime(timeStr string) (nostr.Timestamp, error) {
|
||||
// Handle special cases
|
||||
switch timeStr {
|
||||
case "now":
|
||||
return nostr.Now(), nil
|
||||
}
|
||||
|
||||
// Try to parse as relative time (e.g., "7d", "1h", "30m")
|
||||
if strings.HasSuffix(timeStr, "d") {
|
||||
days := strings.TrimSuffix(timeStr, "d")
|
||||
if daysInt, err := strconv.Atoi(days); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(daysInt*24*60*60), nil
|
||||
}
|
||||
} else if strings.HasSuffix(timeStr, "h") {
|
||||
hours := strings.TrimSuffix(timeStr, "h")
|
||||
if hoursInt, err := strconv.Atoi(hours); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(hoursInt*60*60), nil
|
||||
}
|
||||
} else if strings.HasSuffix(timeStr, "m") {
|
||||
minutes := strings.TrimSuffix(timeStr, "m")
|
||||
if minutesInt, err := strconv.Atoi(minutes); err == nil {
|
||||
return nostr.Now() - nostr.Timestamp(minutesInt*60), nil
|
||||
}
|
||||
}
|
||||
|
||||
// try to parse as direct timestamp
|
||||
if ts, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
|
||||
return nostr.Timestamp(ts), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("invalid time format: %s", timeStr)
|
||||
}
|
||||
|
||||
type SpellHistoryEntry struct {
|
||||
Identifier string `json:"_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
Pointer nostr.EventPointer `json:"pointer"`
|
||||
}
|
||||
|
||||
func logSpellDetails(spell nostr.Event) {
|
||||
nameTag := spell.Tags.Find("name")
|
||||
name := ""
|
||||
if nameTag != nil {
|
||||
name = clampWithEllipsis(nameTag[1], 28)
|
||||
}
|
||||
if name != "" {
|
||||
name = ": " + color.HiMagentaString(name)
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(spell.Content, 50)
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
|
||||
log("running %s%s - %s\n",
|
||||
color.BlueString(identifier),
|
||||
name,
|
||||
desc,
|
||||
)
|
||||
}
|
||||
|
||||
func createSpellEvent(ctx context.Context, filter nostr.Filter, kr nostr.Keyer) nostr.Event {
|
||||
spell := nostr.Event{
|
||||
Kind: 777,
|
||||
Tags: make(nostr.Tags, 0),
|
||||
}
|
||||
|
||||
// add cmd tag
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"cmd", "REQ"})
|
||||
|
||||
// add kinds
|
||||
if len(filter.Kinds) > 0 {
|
||||
kindTag := nostr.Tag{"k"}
|
||||
for _, kind := range filter.Kinds {
|
||||
kindTag = append(kindTag, strconv.Itoa(int(kind)))
|
||||
}
|
||||
spell.Tags = append(spell.Tags, kindTag)
|
||||
}
|
||||
|
||||
// add authors
|
||||
if len(filter.Authors) > 0 {
|
||||
authorsTag := nostr.Tag{"authors"}
|
||||
for _, author := range filter.Authors {
|
||||
authorsTag = append(authorsTag, author.Hex())
|
||||
}
|
||||
spell.Tags = append(spell.Tags, authorsTag)
|
||||
}
|
||||
|
||||
// add ids
|
||||
if len(filter.IDs) > 0 {
|
||||
idsTag := nostr.Tag{"ids"}
|
||||
for _, id := range filter.IDs {
|
||||
idsTag = append(idsTag, id.Hex())
|
||||
}
|
||||
spell.Tags = append(spell.Tags, idsTag)
|
||||
}
|
||||
|
||||
// add tags
|
||||
for tagName, values := range filter.Tags {
|
||||
if len(values) > 0 {
|
||||
tag := nostr.Tag{"tag", tagName}
|
||||
for _, value := range values {
|
||||
tag = append(tag, value)
|
||||
}
|
||||
spell.Tags = append(spell.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// add limit
|
||||
if filter.Limit > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"limit", strconv.Itoa(filter.Limit)})
|
||||
}
|
||||
|
||||
// add since
|
||||
if filter.Since > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"since", strconv.FormatInt(int64(filter.Since), 10)})
|
||||
}
|
||||
|
||||
// add until
|
||||
if filter.Until > 0 {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"until", strconv.FormatInt(int64(filter.Until), 10)})
|
||||
}
|
||||
|
||||
// add search
|
||||
if filter.Search != "" {
|
||||
spell.Tags = append(spell.Tags, nostr.Tag{"search", filter.Search})
|
||||
}
|
||||
|
||||
if err := kr.SignEvent(ctx, &spell); err != nil {
|
||||
log("failed to sign spell: %s\n", err)
|
||||
}
|
||||
|
||||
return spell
|
||||
}
|
||||
6
sync.go
6
sync.go
@@ -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
321
thread_helpers.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip22"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
|
||||
filterTags := nostr.TagMap{
|
||||
"E": []string{discussionID.Hex()},
|
||||
}
|
||||
for key, values := range extraTags {
|
||||
filterTags[key] = values
|
||||
}
|
||||
|
||||
comments := make([]nostr.RelayEvent, 0, 15)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Tags: filterTags,
|
||||
Limit: 500,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
|
||||
comments = append(comments, ie)
|
||||
}
|
||||
|
||||
slices.SortFunc(comments, nostr.CompareRelayEvent)
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
func showThreadWithComments(
|
||||
ctx context.Context,
|
||||
relays []string,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
extraTags nostr.TagMap,
|
||||
) error {
|
||||
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printThreadMetadata(ctx, os.Stdout, evt, status, true)
|
||||
stdout("")
|
||||
stdout(evt.Content)
|
||||
|
||||
if len(comments) > 0 {
|
||||
stdout("")
|
||||
stdout(color.CyanString("comments:"))
|
||||
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printThreadedComments(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
comments []nostr.RelayEvent,
|
||||
discussionID nostr.ID,
|
||||
withColor bool,
|
||||
) {
|
||||
byID := make(map[nostr.ID]struct{}, len(comments)+1)
|
||||
byID[discussionID] = struct{}{}
|
||||
for _, c := range comments {
|
||||
byID[c.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// preload metadata from everybody
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
|
||||
for _, c := range comments {
|
||||
wg.Go(func() {
|
||||
sys.FetchProfileMetadata(ctx, c.PubKey)
|
||||
})
|
||||
|
||||
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := byID[parent.ID]; ok {
|
||||
children[parent.ID] = append(children[parent.ID], c)
|
||||
}
|
||||
}
|
||||
|
||||
for parent := range children {
|
||||
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var render func(parent nostr.ID, depth int)
|
||||
render = func(parent nostr.ID, depth int) {
|
||||
for _, c := range children[parent] {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
author := authorPreview(ctx, c.PubKey)
|
||||
created := c.CreatedAt.Time().Format(time.DateTime)
|
||||
|
||||
if withColor {
|
||||
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
||||
} else {
|
||||
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(c.Content, "\n") {
|
||||
fmt.Fprintln(w, indent+" "+line)
|
||||
}
|
||||
fmt.Fprintln(w, indent+"")
|
||||
|
||||
render(c.ID, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
render(discussionID, 0)
|
||||
}
|
||||
|
||||
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if prefix == "" {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
|
||||
}
|
||||
|
||||
matchCount := 0
|
||||
matched := nostr.RelayEvent{}
|
||||
for _, evt := range events {
|
||||
if strings.HasPrefix(evt.ID.Hex(), prefix) {
|
||||
matched = evt
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
|
||||
if matchCount == 0 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func printThreadMetadata(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
withColors bool,
|
||||
) {
|
||||
label := func(s string) string { return s }
|
||||
value := func(s string) string { return s }
|
||||
statusValue := func(s string) string { return s }
|
||||
if withColors {
|
||||
label = func(s string) string { return color.CyanString(s) }
|
||||
value = func(s string) string { return color.HiWhiteString(s) }
|
||||
statusValue = colorizeGitStatus
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
|
||||
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
|
||||
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
|
||||
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
|
||||
if status != "" {
|
||||
fmt.Fprintln(w, label("status:"), statusValue(status))
|
||||
}
|
||||
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
||||
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
|
||||
fmt.Fprintln(w, label("title:"), value(title[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
}
|
||||
}
|
||||
|
||||
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
|
||||
currentParent := discussion
|
||||
selectedParent := nostr.ZeroID
|
||||
inComments := false
|
||||
|
||||
replyb := strings.Builder{}
|
||||
for _, line := range strings.Split(edited, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
|
||||
inComments = false
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
if replyb.Len() == 0 && line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#>") {
|
||||
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
||||
if quoted == "comments:" {
|
||||
inComments = true
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
// keep track of which comment the reply body shows up below of
|
||||
// so we can assign it as a reply to that specifically
|
||||
fields := strings.Fields(quoted)
|
||||
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
|
||||
currId := fields[0][1 : len(fields[0])-1]
|
||||
for _, comment := range comments {
|
||||
if strings.HasPrefix(comment.ID.Hex(), currId) {
|
||||
currentParent = comment
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// if we reach here this is a line for the reply input from the user
|
||||
replyb.WriteString(line)
|
||||
replyb.WriteByte('\n')
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
|
||||
}
|
||||
|
||||
selectedParent = currentParent.ID
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(replyb.String())
|
||||
if content == "" {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
|
||||
}
|
||||
|
||||
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
|
||||
return content, discussion, nil
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.ID == selectedParent {
|
||||
return content, comment, nil
|
||||
}
|
||||
}
|
||||
|
||||
panic("selected reply parent not found (this never happens)")
|
||||
}
|
||||
|
||||
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
||||
lines := make([]string, 0, len(headerLines)+3)
|
||||
for _, line := range headerLines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "# "+strings.TrimSpace(line))
|
||||
}
|
||||
lines = append(lines,
|
||||
"# write your reply here.",
|
||||
"# lines starting with '#' are ignored.",
|
||||
"",
|
||||
)
|
||||
|
||||
appender := &lineAppender{lines, "#> "}
|
||||
|
||||
printThreadMetadata(ctx, appender, discussion, "", false)
|
||||
|
||||
for _, line := range strings.Split(discussion.Content, "\n") {
|
||||
appender.lines = append(appender.lines, "#> "+line)
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
||||
printThreadedComments(ctx, appender, comments, discussion.ID, false)
|
||||
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
|
||||
}
|
||||
|
||||
return strings.Join(appender.lines, "\n")
|
||||
}
|
||||
|
||||
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, "", "", err
|
||||
}
|
||||
|
||||
meta := sys.FetchProfileMetadata(ctx, pk)
|
||||
return pk, meta.ShortName(), meta.NpubShort(), nil
|
||||
}
|
||||
|
||||
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
|
||||
meta := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
if meta.Name != "" {
|
||||
return meta.ShortName() + " (" + meta.NpubShort() + ")"
|
||||
}
|
||||
return meta.NpubShort()
|
||||
}
|
||||
|
||||
type lineAppender struct {
|
||||
lines []string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (l *lineAppender) Write(b []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
||||
line = strings.TrimRight(line, " ")
|
||||
l.lines = append(l.lines, l.prefix+line)
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
81
validate.go
Normal file
81
validate.go
Normal 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
|
||||
},
|
||||
}
|
||||
25
verify.go
25
verify.go
@@ -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)
|
||||
|
||||
29
wallet.go
29
wallet.go
@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
|
||||
relays := sys.FetchOutboxRelays(ctx, pk, 3)
|
||||
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
|
||||
if w == nil {
|
||||
return nil, nil, fmt.Errorf("error loading walle")
|
||||
return nil, nil, fmt.Errorf("error loading wallet")
|
||||
}
|
||||
|
||||
w.Processed = func(evt nostr.Event, err error) {
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user