mirror of
https://github.com/fiatjaf/nak.git
synced 2026-02-01 06:48:51 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
md5sum: false
|
||||||
sha256sum: false
|
sha256sum: false
|
||||||
compress_assets: 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"
|
|
||||||
50
blossom.go
50
blossom.go
@@ -230,11 +230,55 @@ if any of the files are not found the command will fail, otherwise it will succe
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "mirror",
|
Name: "mirror",
|
||||||
Usage: "",
|
Usage: "mirrors a from a server to another",
|
||||||
Description: ``,
|
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,
|
DisableSliceFlagSeparator: true,
|
||||||
ArgsUsage: "",
|
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
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
|
||||||
|
} else {
|
||||||
|
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': %w", blobURL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(bd)
|
||||||
|
stdout(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
exitIfLineProcessingError(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
299
bunker.go
299
bunker.go
@@ -5,12 +5,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -73,13 +73,7 @@ var bunker = &cli.Command{
|
|||||||
},
|
},
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// read config from file
|
// read config from file
|
||||||
config := struct {
|
config := BunkerConfig{}
|
||||||
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")...)
|
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||||
for i, url := range baseRelaysUrls {
|
for i, url := range baseRelaysUrls {
|
||||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||||
@@ -142,6 +136,15 @@ var bunker = &cli.Command{
|
|||||||
if err := json.Unmarshal(b, &config); err != nil {
|
if err := json.Unmarshal(b, &config); err != nil {
|
||||||
return err
|
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) {
|
} else if !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -150,7 +153,11 @@ var bunker = &cli.Command{
|
|||||||
config.Relays[i] = nostr.NormalizeURL(url)
|
config.Relays[i] = nostr.NormalizeURL(url)
|
||||||
}
|
}
|
||||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
|
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 {
|
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
|
// we don't have any secret key stored, so just use whatever was given via flags
|
||||||
@@ -167,7 +174,9 @@ var bunker = &cli.Command{
|
|||||||
} else {
|
} else {
|
||||||
config.Secret = baseSecret
|
config.Secret = baseSecret
|
||||||
config.Relays = baseRelaysUrls
|
config.Relays = baseRelaysUrls
|
||||||
config.AuthorizedKeys = baseAuthorizedKeys
|
for _, bak := range baseAuthorizedKeys {
|
||||||
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||||
@@ -205,8 +214,17 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
qs := url.Values{}
|
qs := url.Values{}
|
||||||
relayURLs := make([]string, 0, len(config.Relays))
|
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||||
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relayURLs := make([]string, 0, len(allRelays))
|
||||||
|
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
@@ -236,10 +254,22 @@ var bunker = &cli.Command{
|
|||||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||||
|
|
||||||
authorizedKeysStr := ""
|
authorizedKeysStr := ""
|
||||||
if len(config.AuthorizedKeys) != 0 {
|
if len(config.Clients) != 0 {
|
||||||
authorizedKeysStr = "\n authorized keys:"
|
authorizedKeysStr = "\n authorized clients:"
|
||||||
for _, pubkey := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
|
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 +279,8 @@ var bunker = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
preauthorizedFlags := ""
|
preauthorizedFlags := ""
|
||||||
for _, k := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
preauthorizedFlags += " -k " + k.Hex()
|
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||||
}
|
}
|
||||||
for _, s := range authorizedSecrets {
|
for _, s := range authorizedSecrets {
|
||||||
preauthorizedFlags += " -s " + s
|
preauthorizedFlags += " -s " + s
|
||||||
@@ -314,24 +344,84 @@ var bunker = &cli.Command{
|
|||||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||||
Since: nostr.Now(),
|
Since: nostr.Now(),
|
||||||
LimitZero: true,
|
LimitZero: true,
|
||||||
}, nostr.SubscriptionOptions{
|
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||||
Label: "nak-bunker",
|
|
||||||
})
|
|
||||||
|
|
||||||
signer := nip46.NewStaticKeySigner(sec)
|
signer := nip46.NewStaticKeySigner(sec)
|
||||||
handlerWg := sync.WaitGroup{}
|
|
||||||
printLock := sync.Mutex{}
|
// 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), 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
|
// just a gimmick
|
||||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||||
_, cancel := context.WithCancel(ctx)
|
_, cancel := context.WithCancel(ctx)
|
||||||
cancelPreviousBunkerInfoPrint = cancel
|
cancelPreviousBunkerInfoPrint = cancel
|
||||||
|
|
||||||
// asking user for authorization
|
|
||||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
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) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if secret == newSecret {
|
if secret == newSecret {
|
||||||
// store this key
|
// store this key
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||||
// discard this and generate a new secret
|
// discard this and generate a new secret
|
||||||
newSecret = randString(12)
|
newSecret = randString(12)
|
||||||
// print bunker info again after this
|
// print bunker info again after this
|
||||||
@@ -343,43 +433,46 @@ var bunker = &cli.Command{
|
|||||||
if persist != nil {
|
if persist != nil {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for ie := range events {
|
for ie := range events {
|
||||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||||
|
|
||||||
// handle the NIP-46 request event
|
// handle the NIP-46 request event
|
||||||
|
from := ie.Event.PubKey
|
||||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
log("< failed to handle request from %s: %s\n", from, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
|
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
log("~ responding with %s\n", string(jresp))
|
log("~ responding with %s\n", string(jresp))
|
||||||
|
|
||||||
handlerWg.Add(len(relayURLs))
|
// use custom relays if they are defined for this client
|
||||||
for _, relayURL := range relayURLs {
|
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||||
go func(relayURL string) {
|
relays := relayURLs
|
||||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
for _, c := range config.Clients {
|
||||||
err := relay.Publish(ctx, eventResponse)
|
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||||
printLock.Lock()
|
relays = c.CustomRelays
|
||||||
if err == nil {
|
break
|
||||||
log("* sent response through %s\n", relay.URL)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
|
if res.Error == nil {
|
||||||
|
log("* sent response through %s\n", res.Relay.URL)
|
||||||
} else {
|
} else {
|
||||||
log("* failed to send response: %s\n", err)
|
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||||
}
|
}
|
||||||
printLock.Unlock()
|
|
||||||
handlerWg.Done()
|
|
||||||
}
|
}
|
||||||
}(relayURL)
|
|
||||||
}
|
|
||||||
handlerWg.Wait()
|
|
||||||
|
|
||||||
// just after handling one request we trigger this
|
// just after handling one request we trigger this
|
||||||
go func() {
|
go func() {
|
||||||
@@ -404,22 +497,42 @@ var bunker = &cli.Command{
|
|||||||
Name: "connect",
|
Name: "connect",
|
||||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||||
ArgsUsage: "<nostrconnect-uri>",
|
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 {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
if c.Args().Len() != 1 {
|
if c.Args().Len() != 1 {
|
||||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
uri, err := url.Parse(c.Args().First())
|
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||||
if err != nil || uri.Scheme != "nostrconnect" {
|
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||||
return fmt.Errorf("invalid uri")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("this is not implemented yet")
|
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 {
|
type plainOrEncryptedKey struct {
|
||||||
@@ -489,3 +602,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
|||||||
|
|
||||||
return true
|
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: %w\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: %w\n", err))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
log(color.RedString("failed to listen on unix socket %s: %w\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
|
||||||
|
}
|
||||||
|
|||||||
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, nostr.PoolOptions{})
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
55
encode.go
55
encode.go
@@ -25,13 +25,6 @@ var encode = &cli.Command{
|
|||||||
"relays":["wss://nada.zero"],
|
"relays":["wss://nada.zero"],
|
||||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||||
} | nak encode`,
|
} | nak encode`,
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringSliceFlag{
|
|
||||||
Name: "relay",
|
|
||||||
Aliases: []string{"r"},
|
|
||||||
Usage: "attach relay hints to naddr code",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
if c.Args().Len() != 0 {
|
if c.Args().Len() != 0 {
|
||||||
@@ -126,7 +119,12 @@ var encode = &cli.Command{
|
|||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "relay",
|
Name: "relay",
|
||||||
Aliases: []string{"r"},
|
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,
|
DisableSliceFlagSeparator: true,
|
||||||
@@ -139,6 +137,13 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -159,6 +164,16 @@ var encode = &cli.Command{
|
|||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
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,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -171,6 +186,13 @@ var encode = &cli.Command{
|
|||||||
|
|
||||||
author := getPubKey(c, "author")
|
author := getPubKey(c, "author")
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,6 +226,16 @@ var encode = &cli.Command{
|
|||||||
Usage: "kind of referred replaceable event",
|
Usage: "kind of referred replaceable event",
|
||||||
Required: true,
|
Required: true,
|
||||||
},
|
},
|
||||||
|
&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,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -224,6 +256,13 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
5
event.go
5
event.go
@@ -24,7 +24,7 @@ const (
|
|||||||
CATEGORY_EXTRAS = "EXTRAS"
|
CATEGORY_EXTRAS = "EXTRAS"
|
||||||
)
|
)
|
||||||
|
|
||||||
var event = &cli.Command{
|
var eventCmd = &cli.Command{
|
||||||
Name: "event",
|
Name: "event",
|
||||||
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
|
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.
|
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.
|
||||||
@@ -145,7 +145,7 @@ example:
|
|||||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||||
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -155,6 +155,7 @@ example:
|
|||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var filter = &cli.Command{
|
var filterCmd = &cli.Command{
|
||||||
Name: "filter",
|
Name: "filter",
|
||||||
Usage: "applies an event filter to an event to see if it matches.",
|
Usage: "applies an event filter to an event to see if it matches.",
|
||||||
Description: `
|
Description: `
|
||||||
|
|||||||
56
flags.go
56
flags.go
@@ -11,6 +11,62 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"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 NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||||
|
|
||||||
type naturalTimeValue struct {
|
type naturalTimeValue struct {
|
||||||
|
|||||||
2
fs.go
2
fs.go
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !openbsd
|
//go:build !windows && !openbsd && !cgofuse
|
||||||
|
|
||||||
package main
|
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
|
package main
|
||||||
|
|
||||||
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
|
|||||||
Description: `doesn't work on Windows and OpenBSD.`,
|
Description: `doesn't work on Windows and OpenBSD.`,
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
237
gift.go
237
gift.go
@@ -4,10 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/keyer"
|
||||||
"fiatjaf.com/nostr/nip44"
|
"fiatjaf.com/nostr/nip44"
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/mailru/easyjson"
|
"github.com/mailru/easyjson"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
@@ -16,19 +20,29 @@ var gift = &cli.Command{
|
|||||||
Name: "gift",
|
Name: "gift",
|
||||||
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
||||||
Description: `example:
|
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,
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "wrap",
|
Name: "wrap",
|
||||||
Flags: append(
|
Flags: []cli.Flag{
|
||||||
defaultKeyFlags,
|
|
||||||
&PubKeyFlag{
|
&PubKeyFlag{
|
||||||
Name: "recipient-pubkey",
|
Name: "recipient-pubkey",
|
||||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||||
Required: true,
|
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",
|
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
|
||||||
Description: `example:
|
Description: `example:
|
||||||
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient := getPubKey(c, "recipient-pubkey")
|
// get sender pubkey (ourselves)
|
||||||
|
|
||||||
// get sender pubkey
|
|
||||||
sender, err := kr.GetPublicKey(ctx)
|
sender, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
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
|
// read event from stdin
|
||||||
for eventJSON := range getJsonsOrBlank() {
|
for eventJSON := range getJsonsOrBlank() {
|
||||||
if eventJSON == "{}" {
|
if eventJSON == "{}" {
|
||||||
@@ -65,7 +111,7 @@ var gift = &cli.Command{
|
|||||||
|
|
||||||
// create seal
|
// create seal
|
||||||
rumorJSON, _ := easyjson.Marshal(rumor)
|
rumorJSON, _ := easyjson.Marshal(rumor)
|
||||||
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
|
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,22 +160,30 @@ var gift = &cli.Command{
|
|||||||
Name: "unwrap",
|
Name: "unwrap",
|
||||||
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
||||||
Description: `example:
|
Description: `example:
|
||||||
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`,
|
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
|
||||||
Flags: append(
|
|
||||||
defaultKeyFlags,
|
|
||||||
&PubKeyFlag{
|
|
||||||
Name: "sender-pubkey",
|
|
||||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||||
|
}
|
||||||
|
ciphers = append(ciphers, kr)
|
||||||
|
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||||
|
}
|
||||||
|
|
||||||
// read gift-wrapped event from stdin
|
// read gift-wrapped event from stdin
|
||||||
for wrapJSON := range getJsonsOrBlank() {
|
for wrapJSON := range getJsonsOrBlank() {
|
||||||
@@ -146,36 +200,79 @@ var gift = &cli.Command{
|
|||||||
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
ephemeralPubkey := wrap.PubKey
|
// 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
|
||||||
|
|
||||||
// decrypt seal
|
// try both the receiver identity key and decoupled key
|
||||||
sealJSON, err := kr.Decrypt(ctx, wrap.Content, ephemeralPubkey)
|
err = nil
|
||||||
if err != nil {
|
for c, potentialCipher := range ciphers {
|
||||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
switch c {
|
||||||
|
case 0:
|
||||||
|
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||||
|
case 1:
|
||||||
|
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||||
}
|
}
|
||||||
|
|
||||||
var seal nostr.Event
|
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||||
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
|
if thisErr != nil {
|
||||||
return fmt.Errorf("invalid seal JSON: %w", err)
|
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 {
|
||||||
|
// if both ciphers failed above we'll reach here
|
||||||
|
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if seal.Kind != 13 {
|
if seal.Kind != 13 {
|
||||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrypt rumor
|
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||||
rumorJSON, err := kr.Decrypt(ctx, seal.Content, sender)
|
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||||
if err != nil {
|
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 {
|
||||||
|
switch s {
|
||||||
|
case 0:
|
||||||
|
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||||
|
case 1:
|
||||||
|
log("- trying the sender's identity 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 {
|
||||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// output the unwrapped event (rumor)
|
// output the unwrapped event (rumor)
|
||||||
stdout(rumorJSON)
|
stdout(rumor.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -190,3 +287,73 @@ func randomNow() nostr.Timestamp {
|
|||||||
randomOffset := rand.Int63n(twoDays)
|
randomOffset := rand.Int63n(twoDays)
|
||||||
return nostr.Timestamp(now - randomOffset)
|
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
|
||||||
|
}
|
||||||
|
|||||||
189
git.go
189
git.go
@@ -181,7 +181,7 @@ aside from those, there is also:
|
|||||||
var fetchedRepo *nip34.Repository
|
var fetchedRepo *nip34.Repository
|
||||||
if existingConfig.Identifier == "" {
|
if existingConfig.Identifier == "" {
|
||||||
log(" searching for existing events... ")
|
log(" searching for existing events... ")
|
||||||
repo, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
repo, _, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
||||||
if err == nil && repo.Event.ID != nostr.ZeroID {
|
if err == nil && repo.Event.ID != nostr.ZeroID {
|
||||||
fetchedRepo = &repo
|
fetchedRepo = &repo
|
||||||
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
||||||
@@ -277,6 +277,10 @@ aside from those, there is also:
|
|||||||
|
|
||||||
// prompt for web URLs
|
// prompt for web URLs
|
||||||
webURLs, err := promptForStringList("web URLs", config.Web, []string{
|
webURLs, err := promptForStringList("web URLs", config.Web, []string{
|
||||||
|
fmt.Sprintf("https://viewsource.win/%s/%s",
|
||||||
|
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
||||||
|
config.Identifier,
|
||||||
|
),
|
||||||
fmt.Sprintf("https://gitworkshop.dev/%s/%s",
|
fmt.Sprintf("https://gitworkshop.dev/%s/%s",
|
||||||
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
||||||
config.Identifier,
|
config.Identifier,
|
||||||
@@ -367,7 +371,7 @@ aside from those, there is also:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository metadata and state
|
// fetch repository metadata and state
|
||||||
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -455,11 +459,17 @@ aside from those, there is also:
|
|||||||
{
|
{
|
||||||
Name: "push",
|
Name: "push",
|
||||||
Usage: "push git changes",
|
Usage: "push git changes",
|
||||||
Flags: append(defaultKeyFlags, &cli.BoolFlag{
|
Flags: append(defaultKeyFlags,
|
||||||
|
&cli.BoolFlag{
|
||||||
Name: "force",
|
Name: "force",
|
||||||
Aliases: []string{"f"},
|
Aliases: []string{"f"},
|
||||||
Usage: "force push to git remotes",
|
Usage: "force push to git remotes",
|
||||||
}),
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "tags",
|
||||||
|
Usage: "push all refs under refs/tags",
|
||||||
|
},
|
||||||
|
),
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// setup signer
|
// setup signer
|
||||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
@@ -526,6 +536,40 @@ aside from those, there is also:
|
|||||||
log("- setting HEAD to branch %s\n", color.CyanString(remoteBranch))
|
log("- setting HEAD to branch %s\n", color.CyanString(remoteBranch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Bool("tags") {
|
||||||
|
// add all refs/tags
|
||||||
|
output, err := exec.Command("git", "show-ref", "--tags").Output()
|
||||||
|
if err != nil && err.Error() != "exit status 1" {
|
||||||
|
// exit status 1 is returned when there are no tags, which should be ok for us
|
||||||
|
return fmt.Errorf("failed to get local tags: %s", err)
|
||||||
|
} else {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commitHash := parts[0]
|
||||||
|
ref := parts[1]
|
||||||
|
|
||||||
|
tagName := strings.TrimPrefix(ref, "refs/tags/")
|
||||||
|
|
||||||
|
if !c.Bool("force") {
|
||||||
|
// if --force is not passed then we can't overwrite tags
|
||||||
|
if existingHash, exists := state.Tags[tagName]; exists && existingHash != commitHash {
|
||||||
|
return fmt.Errorf("tag %s that is already published pointing to %s, call with --force to overwrite", tagName, existingHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.Tags[tagName] = commitHash
|
||||||
|
log("- setting tag %s to commit %s\n", color.CyanString(tagName), color.CyanString(commitHash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// create and sign the new state event
|
// create and sign the new state event
|
||||||
newStateEvent := state.ToEvent()
|
newStateEvent := state.ToEvent()
|
||||||
err = kr.SignEvent(ctx, &newStateEvent)
|
err = kr.SignEvent(ctx, &newStateEvent)
|
||||||
@@ -553,6 +597,9 @@ aside from those, there is also:
|
|||||||
if c.Bool("force") {
|
if c.Bool("force") {
|
||||||
pushArgs = append(pushArgs, "--force")
|
pushArgs = append(pushArgs, "--force")
|
||||||
}
|
}
|
||||||
|
if c.Bool("tags") {
|
||||||
|
pushArgs = append(pushArgs, "--tags")
|
||||||
|
}
|
||||||
pushCmd := exec.Command("git", pushArgs...)
|
pushCmd := exec.Command("git", pushArgs...)
|
||||||
pushCmd.Stderr = os.Stderr
|
pushCmd.Stderr = os.Stderr
|
||||||
pushCmd.Stdout = os.Stdout
|
pushCmd.Stdout = os.Stdout
|
||||||
@@ -735,6 +782,98 @@ aside from those, there is also:
|
|||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "status",
|
||||||
|
Usage: "show repository status and synchronization information",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
// read local config
|
||||||
|
localConfig, err := readNip34ConfigFile("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse owner
|
||||||
|
owner, err := parsePubKey(localConfig.Owner)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid owner public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := localConfig.ToRepository()
|
||||||
|
stdout("\n" + color.CyanString("metadata:"))
|
||||||
|
stdout(" identifier:", color.CyanString(repo.ID))
|
||||||
|
stdout(" name:", color.CyanString(repo.Name))
|
||||||
|
stdout(" owner:", color.CyanString(nip19.EncodeNpub(repo.Event.PubKey)))
|
||||||
|
stdout(" description:", color.CyanString(repo.Description))
|
||||||
|
stdout(" web urls:")
|
||||||
|
for _, url := range repo.Web {
|
||||||
|
stdout(" ", url)
|
||||||
|
}
|
||||||
|
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
|
||||||
|
|
||||||
|
// fetch repository announcement and state from relays
|
||||||
|
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||||
|
if err != nil {
|
||||||
|
// create a local repo object for display purposes
|
||||||
|
log("failed to fetch repository announcement from relays: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil {
|
||||||
|
stdout(color.YellowString("\n repository state not published."))
|
||||||
|
}
|
||||||
|
|
||||||
|
stateHEAD, _ := state.Branches[state.HEAD]
|
||||||
|
|
||||||
|
stdout("\n" + color.CyanString("grasp status:"))
|
||||||
|
rows := make([][3]string, len(localConfig.GraspServers))
|
||||||
|
for s, server := range localConfig.GraspServers {
|
||||||
|
row := [3]string{}
|
||||||
|
|
||||||
|
url := graspServerHost(server)
|
||||||
|
row[0] = url
|
||||||
|
|
||||||
|
upToDate := upToDateRelays != nil && slices.ContainsFunc(upToDateRelays, func(s string) bool { return graspServerHost(s) == url })
|
||||||
|
if upToDate {
|
||||||
|
row[1] = color.GreenString("announcement up-to-date")
|
||||||
|
} else {
|
||||||
|
row[1] = color.YellowString("announcement outdated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != nil {
|
||||||
|
remoteName := gitRemoteName(url)
|
||||||
|
refSpec := fmt.Sprintf("refs/remotes/%s/HEAD", remoteName)
|
||||||
|
lsRemoteCmd := exec.Command("git", "rev-parse", "--verify", refSpec)
|
||||||
|
commitOutput, err := lsRemoteCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
row[2] = color.YellowString("repository not pushed")
|
||||||
|
} else {
|
||||||
|
commit := strings.TrimSpace(string(commitOutput))
|
||||||
|
if commit == stateHEAD {
|
||||||
|
row[2] = color.GreenString("repository synced with state")
|
||||||
|
} else {
|
||||||
|
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows[s] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCol := [3]int{}
|
||||||
|
for i := range maxCol {
|
||||||
|
for _, row := range rows {
|
||||||
|
if len(row[i]) > maxCol[i] {
|
||||||
|
maxCol[i] = len(row[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
line := " " + row[0] + strings.Repeat(" ", maxCol[0]-len(row[0])) + " " + strings.Repeat(" ", maxCol[1]-len(row[1])) + row[1] + " " + strings.Repeat(" ", maxCol[2]-len(row[2])) + row[2]
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +961,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository announcement and state from relays
|
// fetch repository announcement and state from relays
|
||||||
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||||
notUpToDate := func(graspServer string) bool {
|
notUpToDate := func(graspServer string) bool {
|
||||||
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
||||||
}
|
}
|
||||||
@@ -842,9 +981,14 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
}
|
}
|
||||||
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
||||||
}
|
}
|
||||||
|
var event nostr.Event
|
||||||
|
if upToDateAnnouncementEvent != nil {
|
||||||
|
// publish the latest event to the other relays
|
||||||
|
event = *upToDateAnnouncementEvent
|
||||||
|
repo = nip34.ParseRepository(event)
|
||||||
|
} else {
|
||||||
// create a local repository object from config and publish it
|
// create a local repository object from config and publish it
|
||||||
localRepo := localConfig.ToRepository()
|
localRepo := localConfig.ToRepository()
|
||||||
|
|
||||||
if signer != nil {
|
if signer != nil {
|
||||||
signerPk, err := signer.GetPublicKey(ctx)
|
signerPk, err := signer.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -853,23 +997,25 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
if signerPk != owner {
|
if signerPk != owner {
|
||||||
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
||||||
} else {
|
} else {
|
||||||
event := localRepo.ToEvent()
|
event = localRepo.ToEvent()
|
||||||
if err := signer.SignEvent(ctx, &event); err != nil {
|
if err := signer.SignEvent(ctx, &event); err != nil {
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
||||||
|
}
|
||||||
|
|
||||||
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
repo = localRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
||||||
} else {
|
} else {
|
||||||
log("> published to %s\n", color.GreenString(res.RelayURL))
|
log("> published to %s\n", color.GreenString(res.RelayURL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repo = localRepo
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(StateErr); ok {
|
if _, ok := err.(StateErr); ok {
|
||||||
@@ -904,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
} else {
|
} else {
|
||||||
log("local configuration is newer, publishing updated repository announcement...\n")
|
log("local configuration is newer, publishing updated repository announcement...\n")
|
||||||
announcementEvent := localRepo.ToEvent()
|
announcementEvent := localRepo.ToEvent()
|
||||||
|
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
|
||||||
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1208,7 @@ func gitUpdateRefs(ctx context.Context, dir string, state nip34.RepositoryState)
|
|||||||
lines := strings.Split(string(output), "\n")
|
lines := strings.Split(string(output), "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) >= 2 && strings.Contains(parts[1], "refs/remotes/nip34/state/") {
|
if len(parts) >= 2 && strings.Contains(parts[1], "refs/heads/nip34/state/") {
|
||||||
delCmd := exec.Command("git", "update-ref", "-d", parts[1])
|
delCmd := exec.Command("git", "update-ref", "-d", parts[1])
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
delCmd.Dir = dir
|
delCmd.Dir = dir
|
||||||
@@ -1078,7 +1225,7 @@ func gitUpdateRefs(ctx context.Context, dir string, state nip34.RepositoryState)
|
|||||||
branchName = "refs/heads/" + branchName
|
branchName = "refs/heads/" + branchName
|
||||||
}
|
}
|
||||||
|
|
||||||
refName := "refs/remotes/nip34/state/" + strings.TrimPrefix(branchName, "refs/heads/")
|
refName := "refs/heads/nip34/state/" + strings.TrimPrefix(branchName, "refs/heads/")
|
||||||
updateCmd := exec.Command("git", "update-ref", refName, commit)
|
updateCmd := exec.Command("git", "update-ref", refName, commit)
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
updateCmd.Dir = dir
|
updateCmd.Dir = dir
|
||||||
@@ -1091,7 +1238,7 @@ func gitUpdateRefs(ctx context.Context, dir string, state nip34.RepositoryState)
|
|||||||
// create ref for HEAD
|
// create ref for HEAD
|
||||||
if state.HEAD != "" {
|
if state.HEAD != "" {
|
||||||
if headCommit, ok := state.Branches[state.HEAD]; ok {
|
if headCommit, ok := state.Branches[state.HEAD]; ok {
|
||||||
headRefName := "refs/remotes/nip34/state/HEAD"
|
headRefName := "refs/heads/nip34/state/HEAD"
|
||||||
updateCmd := exec.Command("git", "update-ref", headRefName, headCommit)
|
updateCmd := exec.Command("git", "update-ref", headRefName, headCommit)
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
updateCmd.Dir = dir
|
updateCmd.Dir = dir
|
||||||
@@ -1108,7 +1255,7 @@ func fetchRepositoryAndState(
|
|||||||
pubkey nostr.PubKey,
|
pubkey nostr.PubKey,
|
||||||
identifier string,
|
identifier string,
|
||||||
relayHints []string,
|
relayHints []string,
|
||||||
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||||
// fetch repository announcement (30617)
|
// fetch repository announcement (30617)
|
||||||
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
@@ -1129,13 +1276,15 @@ func fetchRepositoryAndState(
|
|||||||
|
|
||||||
// reset this list as the previous was for relays with the older version
|
// reset this list as the previous was for relays with the older version
|
||||||
upToDateRelays = []string{ie.Relay.URL}
|
upToDateRelays = []string{ie.Relay.URL}
|
||||||
|
|
||||||
|
upToDateAnnouncementEvent = &ie.Event
|
||||||
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
||||||
// we discard this because it's the same, but this relay is up-to-date
|
// we discard this because it's the same, but this relay is up-to-date
|
||||||
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if repo.Event.ID == nostr.ZeroID {
|
if repo.Event.ID == nostr.ZeroID {
|
||||||
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository state (30618)
|
// fetch repository state (30618)
|
||||||
@@ -1165,10 +1314,10 @@ func fetchRepositoryAndState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stateErr != nil {
|
if stateErr != nil {
|
||||||
return repo, upToDateRelays, state, stateErr
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo, upToDateRelays, state, nil
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateErr struct{ string }
|
type StateErr struct{ string }
|
||||||
|
|||||||
13
go.mod
13
go.mod
@@ -3,8 +3,7 @@ module github.com/fiatjaf/nak
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/lib v0.3.1
|
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
|
||||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157
|
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
github.com/bep/debounce v1.2.1
|
github.com/bep/debounce v1.2.1
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||||
@@ -12,7 +11,6 @@ require (
|
|||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||||
github.com/fatih/color v1.16.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/json-iterator/go v1.1.12
|
||||||
github.com/liamg/magic v0.0.1
|
github.com/liamg/magic v0.0.1
|
||||||
github.com/mailru/easyjson v0.9.1
|
github.com/mailru/easyjson v0.9.1
|
||||||
@@ -24,14 +22,21 @@ require (
|
|||||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
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/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fiatjaf.com/lib v0.3.2
|
||||||
|
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // 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/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
@@ -68,7 +73,6 @@ require (
|
|||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // 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/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/magefile/mage v1.14.0 // indirect
|
github.com/magefile/mage v1.14.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
@@ -96,7 +100,6 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 // 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/crypto v0.39.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
|||||||
20
go.sum
20
go.sum
@@ -1,7 +1,7 @@
|
|||||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157 h1:14yLsO2HwpS2CLIKFvLMDp8tVEDahwdC8OeG6NGaL+M=
|
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4 h1:DF/4NSbCvXqIIRrwYp7L3S0SqC7/IhQl8mHkmYA5uXM=
|
||||||
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
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/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||||
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
||||||
@@ -144,8 +144,8 @@ 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-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 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||||
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
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.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
|
||||||
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
@@ -200,8 +200,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f
|
|||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
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.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -269,6 +269,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/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 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
@@ -281,8 +283,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 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 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
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-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
|||||||
586
group.go
Normal file
586
group.go
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip29"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var group = &cli.Command{
|
||||||
|
Name: "group",
|
||||||
|
Aliases: []string{"nip29"},
|
||||||
|
Usage: "group-related operations: info, chat, forum, members, admins, roles",
|
||||||
|
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "info",
|
||||||
|
Usage: "show group information",
|
||||||
|
Description: "displays basic group metadata.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
|
||||||
|
stdout("name:", color.HiBlueString(group.Name))
|
||||||
|
stdout("picture:", color.HiBlueString(group.Picture))
|
||||||
|
stdout("about:", color.HiBlueString(group.About))
|
||||||
|
stdout("restricted:",
|
||||||
|
color.HiBlueString("%s", cond(group.Restricted, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Restricted, "only explicit members can publish", "non-members can publish (restricted by relay policy)"),
|
||||||
|
)
|
||||||
|
stdout("closed:",
|
||||||
|
color.HiBlueString("%s", cond(group.Closed, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Closed, "joining requires an invite", "anyone can join (restricted by relay policy)"),
|
||||||
|
)
|
||||||
|
stdout("hidden:",
|
||||||
|
color.HiBlueString("%s", cond(group.Hidden, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Hidden, "group doesn't show up when listing relay groups", "group is visible to users browsing the relay"),
|
||||||
|
)
|
||||||
|
stdout("private:",
|
||||||
|
color.HiBlueString("%s", cond(group.Private, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "members",
|
||||||
|
Usage: "list and manage group members",
|
||||||
|
Description: "view group membership information.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupMembers},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInMembersEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string)
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
for member, roles := range group.Members {
|
||||||
|
wg.Go(func() {
|
||||||
|
line := member.Hex()
|
||||||
|
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, member)
|
||||||
|
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||||
|
|
||||||
|
for _, role := range roles {
|
||||||
|
line += ", " + role.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
lines <- line
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for line := range lines {
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "admins",
|
||||||
|
Usage: "manage group administrators",
|
||||||
|
Description: "view and manage group admin permissions.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupAdmins},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInAdminsEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string)
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
for member, roles := range group.Members {
|
||||||
|
wg.Go(func() {
|
||||||
|
line := member.Hex()
|
||||||
|
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, member)
|
||||||
|
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||||
|
|
||||||
|
for _, role := range roles {
|
||||||
|
line += ", " + role.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
lines <- line
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for line := range lines {
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "roles",
|
||||||
|
Usage: "manage group roles and permissions",
|
||||||
|
Description: "configure custom roles and permissions within the group.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Roles: make([]*nip29.Role, 0),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupRoles},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInRolesEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range group.Roles {
|
||||||
|
stdout(color.HiBlueString(role.Name) + " " + role.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chat",
|
||||||
|
Usage: "send and read group chat messages",
|
||||||
|
Description: "interact with group chat functionality.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := r.Subscribe(ctx, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9},
|
||||||
|
Tags: nostr.TagMap{"h": []string{identifier}},
|
||||||
|
Limit: 200,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
eosed := false
|
||||||
|
messages := make([]struct {
|
||||||
|
message string
|
||||||
|
rendered bool
|
||||||
|
}, 200)
|
||||||
|
base := len(messages)
|
||||||
|
|
||||||
|
tryRender := func(i int) {
|
||||||
|
// if all messages before these are loaded we can render this,
|
||||||
|
// otherwise we render whatever we can and stop
|
||||||
|
for m, msg := range messages[base:] {
|
||||||
|
if msg.rendered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.message == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messages[base+m].rendered = true
|
||||||
|
stdout(msg.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt := <-sub.Events:
|
||||||
|
var i int
|
||||||
|
if eosed {
|
||||||
|
i = len(messages)
|
||||||
|
messages = append(messages, struct {
|
||||||
|
message string
|
||||||
|
rendered bool
|
||||||
|
}{})
|
||||||
|
} else {
|
||||||
|
base--
|
||||||
|
i = base
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||||
|
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
|
||||||
|
|
||||||
|
if eosed {
|
||||||
|
tryRender(i)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case reason := <-sub.ClosedReason:
|
||||||
|
stdout("closed:" + color.YellowString(reason))
|
||||||
|
case <-sub.EndOfStoredEvents:
|
||||||
|
eosed = true
|
||||||
|
tryRender(len(messages) - 1)
|
||||||
|
case <-sub.Context.Done():
|
||||||
|
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "send",
|
||||||
|
Usage: "sends a message to the chat",
|
||||||
|
ArgsUsage: "<relay>'<identifier> <message>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := nostr.Event{
|
||||||
|
Kind: 9,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: strings.Join(c.Args().Tail(), " "),
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"h", identifier},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &msg); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
return r.Publish(ctx, msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "forum",
|
||||||
|
Usage: "read group forum posts",
|
||||||
|
Description: "access group forum functionality.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{11},
|
||||||
|
Tags: nostr.TagMap{"#h": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
title := evt.Tags.Find("title")
|
||||||
|
if title != nil {
|
||||||
|
stdout(colors.bold(title[1]))
|
||||||
|
} else {
|
||||||
|
stdout(colors.bold("<untitled>"))
|
||||||
|
}
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||||
|
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
|
||||||
|
stdout(evt.Content)
|
||||||
|
}
|
||||||
|
// TODO: see what to do about this
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "put-user",
|
||||||
|
Usage: "add a user to the group with optional roles",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&PubKeyFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "role",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
|
||||||
|
pubkey := getPubKey(c, "pubkey")
|
||||||
|
tag := nostr.Tag{"p", pubkey.Hex()}
|
||||||
|
tag = append(tag, c.StringSlice("role")...)
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove-user",
|
||||||
|
Usage: "remove a user from the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier> <pubkey>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&PubKeyFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
|
||||||
|
pubkey := getPubKey(c, "pubkey")
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "edit-metadata",
|
||||||
|
Usage: "edits the group metadata",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "name",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "about",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "picture",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "restricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "unrestricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "closed",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "open",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "hidden",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "visible",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "private",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "public",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
||||||
|
if name := c.String("name"); name != "" {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
|
||||||
|
}
|
||||||
|
if picture := c.String("picture"); picture != "" {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
|
||||||
|
}
|
||||||
|
if about := c.String("about"); about != "" {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
|
||||||
|
}
|
||||||
|
if c.Bool("restricted") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
|
||||||
|
} else if c.Bool("unrestricted") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
||||||
|
}
|
||||||
|
if c.Bool("closed") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
||||||
|
} else if c.Bool("open") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
||||||
|
}
|
||||||
|
if c.Bool("hidden") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
||||||
|
} else if c.Bool("visible") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
|
||||||
|
}
|
||||||
|
if c.Bool("private") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
||||||
|
} else if c.Bool("public") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete-event",
|
||||||
|
Usage: "delete an event from the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&IDFlag{
|
||||||
|
Name: "event",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
|
||||||
|
id := getID(c, "event")
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete-group",
|
||||||
|
Usage: "deletes the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create-invite",
|
||||||
|
Usage: "creates an invite code",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "code",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("requires group identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: kind,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: "",
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"h", identifier},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setupFunc(&evt, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(evt.String())
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Publish(ctx, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cond(b bool, ifYes string, ifNo string) string {
|
||||||
|
if b {
|
||||||
|
return ifYes
|
||||||
|
}
|
||||||
|
return ifNo
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err error) {
|
||||||
|
groupArg := c.Args().First()
|
||||||
|
if !strings.Contains(groupArg, "'") {
|
||||||
|
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(groupArg, "'", 2)
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
|
||||||
|
}
|
||||||
16
helpers.go
16
helpers.go
@@ -46,8 +46,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func isPiped() bool {
|
func isPiped() bool {
|
||||||
stat, _ := os.Stdin.Stat()
|
stat, err := os.Stdin.Stat()
|
||||||
return stat.Mode()&os.ModeCharDevice == 0
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := stat.Mode()
|
||||||
|
is := mode&os.ModeCharDevice == 0
|
||||||
|
return is
|
||||||
}
|
}
|
||||||
|
|
||||||
func getJsonsOrBlank() iter.Seq[string] {
|
func getJsonsOrBlank() iter.Seq[string] {
|
||||||
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if !hasStdin && !isPiped() {
|
if !hasStdin {
|
||||||
yield("{}")
|
yield("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +541,8 @@ var colors = struct {
|
|||||||
italicf func(string, ...any) string
|
italicf func(string, ...any) string
|
||||||
bold func(...any) string
|
bold func(...any) string
|
||||||
boldf func(string, ...any) string
|
boldf func(string, ...any) string
|
||||||
|
underline func(...any) string
|
||||||
|
underlinef func(string, ...any) string
|
||||||
error func(...any) string
|
error func(...any) string
|
||||||
errorf func(string, ...any) string
|
errorf func(string, ...any) string
|
||||||
success func(...any) string
|
success func(...any) string
|
||||||
@@ -545,6 +553,8 @@ var colors = struct {
|
|||||||
color.New(color.Italic).Sprintf,
|
color.New(color.Italic).Sprintf,
|
||||||
color.New(color.Bold).Sprint,
|
color.New(color.Bold).Sprint,
|
||||||
color.New(color.Bold).Sprintf,
|
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).Sprint,
|
||||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||||
|
|||||||
7
key.go
7
key.go
@@ -279,13 +279,14 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sk = data.(nostr.SecretKey)
|
sk = data.(nostr.SecretKey)
|
||||||
}
|
} else {
|
||||||
|
var err error
|
||||||
sk, err := nostr.SecretKeyFromHex(sec)
|
sk, err = nostr.SecretKeyFromHex(sec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ch <- sk
|
ch <- sk
|
||||||
}
|
}
|
||||||
|
|||||||
43
lmdb.go
Normal file
43
lmdb.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//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 != "" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
main.go
16
main.go
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,9 +25,9 @@ var app = &cli.Command{
|
|||||||
Usage: "the nostr army knife command-line tool",
|
Usage: "the nostr army knife command-line tool",
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
event,
|
eventCmd,
|
||||||
req,
|
req,
|
||||||
filter,
|
filterCmd,
|
||||||
fetch,
|
fetch,
|
||||||
count,
|
count,
|
||||||
decode,
|
decode,
|
||||||
@@ -40,18 +39,21 @@ var app = &cli.Command{
|
|||||||
bunker,
|
bunker,
|
||||||
serve,
|
serve,
|
||||||
blossomCmd,
|
blossomCmd,
|
||||||
|
dekey,
|
||||||
encrypt,
|
encrypt,
|
||||||
decrypt,
|
decrypt,
|
||||||
gift,
|
gift,
|
||||||
outbox,
|
outboxCmd,
|
||||||
wallet,
|
wallet,
|
||||||
mcpServer,
|
mcpServer,
|
||||||
curl,
|
curl,
|
||||||
fsCmd,
|
fsCmd,
|
||||||
publish,
|
publish,
|
||||||
git,
|
git,
|
||||||
|
group,
|
||||||
nip,
|
nip,
|
||||||
syncCmd,
|
syncCmd,
|
||||||
|
spell,
|
||||||
},
|
},
|
||||||
Version: version,
|
Version: version,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
@@ -62,7 +64,7 @@ var app = &cli.Command{
|
|||||||
if home, err := os.UserHomeDir(); err == nil {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
return filepath.Join(home, ".config/nak")
|
return filepath.Join(home, ".config/nak")
|
||||||
} else {
|
} else {
|
||||||
return filepath.Join("/dev/null")
|
return ""
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
@@ -98,9 +100,7 @@ var app = &cli.Command{
|
|||||||
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
|
||||||
sys = sdk.NewSystem()
|
sys = sdk.NewSystem()
|
||||||
|
|
||||||
if err := initializeOutboxHintsDB(c, sys); err != nil {
|
setupLocalDatabases(c, sys)
|
||||||
return ctx, fmt.Errorf("failed to initialize outbox hints: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sys.Pool = nostr.NewPool(nostr.PoolOptions{
|
sys.Pool = nostr.NewPool(nostr.PoolOptions{
|
||||||
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
|
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
|
||||||
|
|||||||
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
61
outbox.go
61
outbox.go
@@ -3,80 +3,21 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr/sdk"
|
|
||||||
"fiatjaf.com/nostr/sdk/hints/bbolth"
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var outboxCmd = &cli.Command{
|
||||||
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{
|
|
||||||
Name: "outbox",
|
Name: "outbox",
|
||||||
Usage: "manage outbox relay hints database",
|
Usage: "manage outbox relay hints database",
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Commands: []*cli.Command{
|
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",
|
Name: "list",
|
||||||
Usage: "list outbox relays for a given pubkey",
|
Usage: "list outbox relays for a given pubkey",
|
||||||
ArgsUsage: "<pubkey>",
|
ArgsUsage: "<pubkey>",
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
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 {
|
if c.Args().Len() != 1 {
|
||||||
return fmt.Errorf("expected exactly one argument (pubkey)")
|
return fmt.Errorf("expected exactly one argument (pubkey)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ example:
|
|||||||
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
||||||
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
129
req.go
129
req.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/eventstore"
|
"fiatjaf.com/nostr/eventstore"
|
||||||
@@ -77,11 +78,6 @@ example:
|
|||||||
Name: "paginate-interval",
|
Name: "paginate-interval",
|
||||||
Usage: "time between queries when using --paginate",
|
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{
|
&cli.BoolFlag{
|
||||||
Name: "bare",
|
Name: "bare",
|
||||||
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
|
||||||
@@ -96,6 +92,10 @@ example:
|
|||||||
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||||
Category: CATEGORY_SIGNER,
|
Category: CATEGORY_SIGNER,
|
||||||
},
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "spell",
|
||||||
|
Usage: "output a spell event (kind 777) instead of a filter",
|
||||||
|
},
|
||||||
)...,
|
)...,
|
||||||
),
|
),
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
@@ -115,7 +115,16 @@ example:
|
|||||||
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Bool("bare") && c.Bool("spell") {
|
||||||
|
return fmt.Errorf("incompatible flags --bare and --spell")
|
||||||
|
}
|
||||||
|
|
||||||
relayUrls := c.Args().Slice()
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
if len(relayUrls) > 0 && !negentropy {
|
if len(relayUrls) > 0 && !negentropy {
|
||||||
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
|
// 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
|
// connect to all relays we expect to use in this call in parallel
|
||||||
@@ -129,7 +138,7 @@ example:
|
|||||||
relayUrls,
|
relayUrls,
|
||||||
forcePreAuthSigner,
|
forcePreAuthSigner,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {
|
return authSigner(ctx, c, func(s string, args ...any) {
|
||||||
if strings.HasPrefix(s, "authenticating as") {
|
if strings.HasPrefix(s, "authenticating as") {
|
||||||
cleanUrl, _ := strings.CutPrefix(
|
cleanUrl, _ := strings.CutPrefix(
|
||||||
@@ -226,18 +235,62 @@ example:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var results chan nostr.RelayEvent
|
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")
|
||||||
opts := nostr.SubscriptionOptions{
|
}
|
||||||
Label: "nak-req",
|
} else {
|
||||||
|
// no relays given, will just print the filter or spell
|
||||||
|
var result string
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Bool("paginate") {
|
exitIfLineProcessingError(ctx)
|
||||||
paginator := sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func performReq(
|
||||||
|
ctx context.Context,
|
||||||
|
filter nostr.Filter,
|
||||||
|
relayUrls []string,
|
||||||
|
stream bool,
|
||||||
|
outbox bool,
|
||||||
|
outboxRelaysPerPubKey uint64,
|
||||||
|
paginate bool,
|
||||||
|
paginateInterval time.Duration,
|
||||||
|
label string,
|
||||||
|
) {
|
||||||
|
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)
|
results = paginator(ctx, relayUrls, filter, opts)
|
||||||
} else if c.Bool("outbox") {
|
} else if outbox {
|
||||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||||
|
|
||||||
// hardcoded relays, if any
|
|
||||||
for _, relayUrl := range relayUrls {
|
for _, relayUrl := range relayUrls {
|
||||||
defs = append(defs, nostr.DirectedFilter{
|
defs = append(defs, nostr.DirectedFilter{
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
@@ -249,12 +302,13 @@ example:
|
|||||||
errg := errgroup.Group{}
|
errg := errgroup.Group{}
|
||||||
errg.SetLimit(16)
|
errg.SetLimit(16)
|
||||||
mu := sync.Mutex{}
|
mu := sync.Mutex{}
|
||||||
|
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||||
for _, pubkey := range filter.Authors {
|
for _, pubkey := range filter.Authors {
|
||||||
errg.Go(func() error {
|
errg.Go(func() error {
|
||||||
n := int(c.Uint("outbox-relays-per-pubkey"))
|
n := int(outboxRelaysPerPubKey)
|
||||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||||
if slices.Contains(relayUrls, url) {
|
if slices.Contains(relayUrls, url) {
|
||||||
// already hardcoded, ignore
|
// already specified globally, ignore
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !nostr.IsValidRelayURL(url) {
|
if !nostr.IsValidRelayURL(url) {
|
||||||
@@ -293,40 +347,41 @@ example:
|
|||||||
}
|
}
|
||||||
errg.Wait()
|
errg.Wait()
|
||||||
|
|
||||||
if c.Bool("stream") {
|
if stream {
|
||||||
results = sys.Pool.BatchedSubscribeMany(ctx, defs, opts)
|
logverbose("running subscription with %d directed filters...\n", len(defs))
|
||||||
|
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
|
||||||
} else {
|
} else {
|
||||||
results = sys.Pool.BatchedQueryMany(ctx, defs, opts)
|
logverbose("running query with %d directed filters...\n", len(defs))
|
||||||
|
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if c.Bool("stream") {
|
if stream {
|
||||||
results = sys.Pool.SubscribeMany(ctx, relayUrls, filter, opts)
|
logverbose("running subscription to %d relays...\n", len(relayUrls))
|
||||||
|
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||||
} else {
|
} else {
|
||||||
results = sys.Pool.FetchMany(ctx, relayUrls, filter, opts)
|
logverbose("running query to %d relays...\n", len(relayUrls))
|
||||||
|
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for ie := range results {
|
readevents:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ie, ok := <-results:
|
||||||
|
if !ok {
|
||||||
|
break readevents
|
||||||
|
}
|
||||||
stdout(ie.Event)
|
stdout(ie.Event)
|
||||||
}
|
case closed := <-closeds:
|
||||||
}
|
if closed.HandledAuth {
|
||||||
|
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||||
} else {
|
} else {
|
||||||
// no relays given, will just print the filter
|
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||||
var result string
|
|
||||||
if c.Bool("bare") {
|
|
||||||
result = filter.String()
|
|
||||||
} else {
|
|
||||||
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
|
|
||||||
result = string(j)
|
|
||||||
}
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
stdout(result)
|
break readevents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitIfLineProcessingError(ctx)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqFilterFlags = []cli.Flag{
|
var reqFilterFlags = []cli.Flag{
|
||||||
|
|||||||
9
serve.go
9
serve.go
@@ -51,6 +51,12 @@ var serve = &cli.Command{
|
|||||||
Name: "grasp",
|
Name: "grasp",
|
||||||
Usage: "enable grasp server",
|
Usage: "enable grasp server",
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "grasp-path",
|
||||||
|
Usage: "where to store the repositories",
|
||||||
|
TakesFile: true,
|
||||||
|
Hidden: true,
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "blossom",
|
Name: "blossom",
|
||||||
Usage: "enable blossom server",
|
Usage: "enable blossom server",
|
||||||
@@ -135,11 +141,14 @@ var serve = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Bool("grasp") {
|
if c.Bool("grasp") {
|
||||||
|
repoDir = c.String("grasp-path")
|
||||||
|
if repoDir == "" {
|
||||||
var err error
|
var err error
|
||||||
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
|
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create grasp repos directory: %w", err)
|
return fmt.Errorf("failed to create grasp repos directory: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
g := grasp.New(rl, repoDir)
|
g := grasp.New(rl, repoDir)
|
||||||
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
|
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)
|
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
|
||||||
|
|||||||
547
spell.go
Normal file
547
spell.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
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 = entry.Content
|
||||||
|
if len(displayName) > 28 {
|
||||||
|
displayName = displayName[:27] + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if displayName != "" {
|
||||||
|
displayName = color.HiMagentaString(displayName) + ": "
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := entry.Content
|
||||||
|
if len(desc) > 50 {
|
||||||
|
desc = desc[0:49] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell")
|
||||||
|
|
||||||
|
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 = nameTag[1]
|
||||||
|
if len(name) > 28 {
|
||||||
|
name = name[:27] + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
name = ": " + color.HiMagentaString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := spell.Content
|
||||||
|
if len(desc) > 50 {
|
||||||
|
desc = desc[0:49] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user