Compare commits

...

55 Commits

Author SHA1 Message Date
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
fiatjaf
e64ad8f078 git: better printing of server statuses. 2026-01-18 21:44:06 -03:00
fiatjaf
b36718caaa git: status.
and a fix for repository announcements getting updated every time due to time shifts.
2026-01-18 21:32:01 -03:00
fiatjaf
5c658c38f1 bring back old github actions builder. 2026-01-18 14:55:03 -03:00
fiatjaf
2a5ce3b249 blossom mirror to only take a URL and do its thing, not try to list blobs. 2026-01-18 14:47:28 -03:00
fiatjaf
c0b85af734 make cgofuse the default for "fs" only on windows. on linux and mac it needs a "cgofuse" build tag. 2026-01-18 10:54:34 -03:00
Yasuhiro Matsumoto
cb2247c9da implement blossom mirror 2026-01-17 11:07:47 -03:00
mattn
686d960f62 Merge pull request #96 from mattn/fix-release
fix release build
2026-01-17 21:14:28 +09:00
Yasuhiro Matsumoto
af04838153 fix release build 2026-01-17 20:51:01 +09:00
fiatjaf
c6da13649d hopefully eliminate the weird case of cron and githubactions calling nak with an empty stdin and causing it to do nothing.
closes https://github.com/fiatjaf/nak/issues/90
2026-01-16 16:11:07 -03:00
Yasuhiro Matsumoto
acd6227dd0 fix darwin build 2026-01-16 15:17:29 -03:00
mattn
00fbda9af7 use native runner and install macfuse 2026-01-16 13:43:19 -03:00
fiatjaf
e838de9b72 fs: move everything to the top-level directory. 2026-01-16 12:34:09 -03:00
fiatjaf
6dfbed4413 fs: just some renames. 2026-01-16 12:18:32 -03:00
fiatjaf
0e283368ed bunker: authorize preexisting keys first. 2026-01-16 12:15:07 -03:00
mattn
38775e0d93 Use cgofuse (#92) 2026-01-14 22:41:14 -03:00
fiatjaf
fabcad3f61 key: fix stupid error when passing nsec1 code to nak key public. 2026-01-10 09:56:39 -03:00
fiatjaf
69e4895e48 --outbox flag for encode. 2026-01-08 22:15:54 -03:00
fiatjaf
81524de04f gift: unwrap tries both decoupled and identity keys, wrap defaults to decoupled but accepts flags to change that. 2025-12-30 15:28:06 -03:00
fiatjaf
8334474f96 git: add viewsource.win to list of possible web views. 2025-12-29 19:53:07 -03:00
fiatjaf
87f27e214e use dekey by default on gift wrap and unwrap. 2025-12-28 12:49:53 -03:00
fiatjaf
32999917b4 actually run the smoke test. 2025-12-27 14:48:17 -03:00
fiatjaf
a19a179548 dekey: don't publish device announcements when not necessary, delete them when they become unnecessary. 2025-12-24 11:46:43 -03:00
fiatjaf
9b684f2c65 dekey: make it better and fix things, --rotate and other flags, prompts by default etc. 2025-12-24 00:09:50 -03:00
fiatjaf
6d87887855 spell: execute from history using the name, not only the id. 2025-12-23 21:32:54 -03:00
fiatjaf
e9c4deaf6d nak req --spell for creating spells. 2025-12-23 21:31:54 -03:00
fiatjaf
965a312b46 only build with lmdb on linux. 2025-12-22 13:01:26 -03:00
fiatjaf
2e4079f92c freeze nostrlib version. 2025-12-22 12:25:34 -03:00
fiatjaf
5b64795015 git: fix --tags 2025-12-22 12:24:03 -03:00
fiatjaf
5d4fe434c3 spell: also take a --pub to run spells in the context of other users. 2025-12-22 12:11:27 -03:00
fiatjaf
b95665d986 spell: store spells locally and add stdin spell to history. 2025-12-22 11:59:29 -03:00
fiatjaf
3be80c29df spell: fix listing recent. 2025-12-22 00:22:14 -03:00
fiatjaf
e01cfbde47 print spell details before running. 2025-12-22 00:19:10 -03:00
fiatjaf
e91d4429ec switch the local databases to lmdb so they can be accessed by multiple nak instances at the same time. 2025-12-22 00:18:56 -03:00
fiatjaf
21423b4a21 spell: execute from stdin event. 2025-12-21 21:59:44 -03:00
fiatjaf
1b7f3162b5 automatically create hints, store, and kvstore for system always. 2025-12-21 21:58:22 -03:00
fiatjaf
8f38468103 basic grimoire spell support. 2025-12-21 15:11:08 -03:00
fiatjaf
9bf728d850 git: nip34 state as fake heads instead of fake remotes. 2025-12-21 15:11:00 -03:00
fiatjaf
8396738fe2 git: push --tags support. 2025-12-21 15:11:00 -03:00
fiatjaf
c1d1682d6e dekey: add logs. 2025-12-19 14:50:59 -03:00
fiatjaf
6f00ff4c73 bunker: fix a halting waitgroup issue. 2025-12-16 13:13:12 -03:00
fiatjaf
68bbece3db update keypair pee example on readme. 2025-12-16 13:12:56 -03:00
fiatjaf
a83b23d76b add nak git demo to README. 2025-12-05 22:15:08 -03:00
fiatjaf
a288cc47a4 add example of compilation with -tags debug to README. 2025-12-05 22:09:22 -03:00
fiatjaf
5ee7670ba8 req: fix infinite loop when events channel is exhausted. 2025-12-04 13:21:43 -03:00
fiatjaf
b973b476bc req: print CLOSED messages. 2025-12-04 09:24:36 -03:00
fiatjaf
252612b12f add pee trick. 2025-12-04 08:46:20 -03:00
fiatjaf
4b8b6bb3de dekey: nip4e (untested). 2025-12-03 23:08:59 -03:00
fiatjaf
df491be232 serve: --grasp-path (hidden). 2025-12-02 15:53:18 -03:00
30 changed files with 4257 additions and 439 deletions

View File

@@ -47,3 +47,93 @@ jobs:
md5sum: false
sha256sum: false
compress_assets: false
smoke-test-linux-amd64:
runs-on: ubuntu-latest
needs:
- build-all-for-all
steps:
- name: download and smoke test latest binary
run: |
set -eo pipefail # exit on error, and on pipe failures
echo "downloading nak binary from releases"
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
chmod +x nak
echo "printing version..."
./nak --version
# generate and manipulate keys
echo "testing key operations..."
SECRET_KEY=$(./nak key generate)
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
echo "generated key pair: $SECRET_KEY => $PUBLIC_KEY"
# create events
echo "testing event creation..."
./nak event -c "hello world"
HELLOWORLD=$(./nak event -c "hello world")
echo " hello world again: $HELLOWORLD"
./nak event --ts "2 days ago" -c "event with timestamp"
./nak event -k 1 -t "t=test" -c "event with tag"
# test NIP-19 encoding/decoding
echo "testing NIP-19 encoding/decoding..."
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
echo "encoded nsec: $NSEC"
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
EVENT_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
NEVENT1=$(./nak encode nevent $EVENT_ID)
echo "encoded nevent1: $NEVENT1"
./nak decode $NEVENT1
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
# test event verification
echo "testing event verification..."
# create an event and verify it
VERIFY_EVENT=$(./nak event -c "verify me")
echo $VERIFY_EVENT | ./nak verify
# test PoW
echo "testing pow..."
./nak event -c "testing pow" --pow 8
# test NIP-49 key encryption/decryption
echo "testing NIP-49 key encryption/decryption..."
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
echo "nip-49 encryption/decryption test failed!"
exit 1
fi
# test multi-value tags
echo "testing multi-value tags..."
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "testing multi-value tags"
# test relay operations (with a public relay)
echo "testing publishing..."
# publish a simple event to a public relay
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
echo "published event ID: $EVENT_ID"
# wait a moment for propagation
sleep 2
# fetch the event we just published
./nak req -i $EVENT_ID nos.lol
# test serving (just start and immediately kill)
echo "testing serve command..."
timeout 2s ./nak serve || true
# test filesystem mount (just start and immediately kill)
echo "testing fs mount command..."
mkdir -p /tmp/nostr-mount
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
echo "all tests passed"

View File

@@ -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"

File diff suppressed because one or more lines are too long

View File

@@ -229,12 +229,56 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
Name: "mirror",
Usage: "",
Description: ``,
Name: "mirror",
Usage: "mirrors a from a server to another",
Description: `examples:
mirroring a single blob:
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
mirroring all blobs from a certain pubkey from one server to the other:
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
DisableSliceFlagSeparator: true,
ArgsUsage: "",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
var bd blossom.BlobDescriptor
if input := c.Args().First(); input != "" {
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
return err
}
out, _ := json.Marshal(bd)
stdout(out)
return nil
} 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
},
},

299
bunker.go
View File

@@ -5,12 +5,12 @@ import (
"context"
"encoding/hex"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -73,13 +73,7 @@ var bunker = &cli.Command{
},
Action: func(ctx context.Context, c *cli.Command) error {
// read config from file
config := struct {
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
config := BunkerConfig{}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
@@ -142,6 +136,15 @@ var bunker = &cli.Command{
if err := json.Unmarshal(b, &config); err != nil {
return err
}
// convert from deprecated field
if len(config.AuthorizedKeys) > 0 {
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
for i := range config.AuthorizedKeys {
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
}
config.AuthorizedKeys = nil
persist()
}
} else if !os.IsNotExist(err) {
return err
}
@@ -150,7 +153,11 @@ var bunker = &cli.Command{
config.Relays[i] = nostr.NormalizeURL(url)
}
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 {
// 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 {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
config.AuthorizedKeys = baseAuthorizedKeys
for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
// if we got here without any keys set (no flags, first time using a profile), use the default
@@ -205,8 +214,17 @@ var bunker = &cli.Command{
// try to connect to the relays here
qs := url.Values{}
relayURLs := make([]string, 0, len(config.Relays))
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
copy(allRelays, config.Relays)
for _, c := range config.Clients {
for _, url := range c.CustomRelays {
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
allRelays = append(allRelays, url)
}
}
}
relayURLs := make([]string, 0, len(allRelays))
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -236,10 +254,22 @@ var bunker = &cli.Command{
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := ""
if len(config.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized clients:"
for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
}
}
@@ -249,8 +279,8 @@ var bunker = &cli.Command{
}
preauthorizedFlags := ""
for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
for _, c := range config.Clients {
preauthorizedFlags += " -k " + c.PubKey.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -314,24 +344,84 @@ var bunker = &cli.Command{
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
// 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
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
return true
}
if secret == newSecret {
// store this key
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -343,43 +433,46 @@ var bunker = &cli.Command{
if persist != nil {
persist()
}
return true
}
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
return false
}
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
log("< failed to handle request from %s: %s\n", from, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := relayURLs
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
@@ -404,24 +497,44 @@ var bunker = &cli.Command{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name of the bunker to connect to",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri")
}
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
if err := sendToSocket(c, c.Args().First()); err != nil {
return fmt.Errorf("failed to connect to running bunker: %w", err)
}
// TODO
return fmt.Errorf("this is not implemented yet")
return nil
},
},
},
}
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct {
Plain *nostr.SecretKey
Encrypted *string
@@ -489,3 +602,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
return true
}
func getSocketPath(c *cli.Command) string {
profile := "default"
if c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
}
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %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
View 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
},
}

View File

@@ -25,13 +25,6 @@ var encode = &cli.Command{
"relays":["wss://nada.zero"],
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
} | nak encode`,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to naddr code",
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 0 {
@@ -126,7 +119,12 @@ var encode = &cli.Command{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to nprofile code",
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
DisableSliceFlagSeparator: true,
@@ -139,6 +137,13 @@ var encode = &cli.Command{
}
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 {
return err
}
@@ -159,6 +164,16 @@ var encode = &cli.Command{
Aliases: []string{"a"},
Usage: "attach an author pubkey as a hint to the nevent code",
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -171,6 +186,13 @@ var encode = &cli.Command{
author := getPubKey(c, "author")
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 {
return err
}
@@ -204,6 +226,16 @@ var encode = &cli.Command{
Usage: "kind of referred replaceable event",
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,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -224,6 +256,13 @@ var encode = &cli.Command{
}
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 {
return err
}

View File

@@ -24,7 +24,7 @@ const (
CATEGORY_EXTRAS = "EXTRAS"
)
var event = &cli.Command{
var eventCmd = &cli.Command{
Name: "event",
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
@@ -145,7 +145,7 @@ example:
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
@@ -155,6 +155,7 @@ example:
os.Exit(3)
}
}
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err

View File

@@ -9,7 +9,7 @@ import (
"github.com/urfave/cli/v3"
)
var filter = &cli.Command{
var filterCmd = &cli.Command{
Name: "filter",
Usage: "applies an event filter to an event to see if it matches.",
Description: `

View File

@@ -11,6 +11,62 @@ import (
"github.com/urfave/cli/v3"
)
type (
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
)
type boolIntValue struct {
int int
defaultWhenSet int
hasDefault bool
hasBeenSet bool
}
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
*p = val
return &boolIntValue{
defaultWhenSet: val,
hasDefault: true,
}
}
func (t boolIntValue) IsBoolFlag() bool {
return true
}
func (t boolIntValue) ToString(b int) string { return "<<>>" }
func (t *boolIntValue) Set(value string) error {
t.hasBeenSet = true
if value == "true" {
if t.hasDefault {
t.int = t.defaultWhenSet
} else {
t.int = 1
}
return nil
} else {
var err error
t.int, err = strconv.Atoi(value)
return err
}
}
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
func (t *boolIntValue) Value() int { return t.int }
func (t *boolIntValue) Get() any { return t.int }
func getBoolInt(cmd *cli.Command, name string) int {
return cmd.Value(name).(int)
}
//
//
//
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
type naturalTimeValue struct {

2
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows && !openbsd
//go:build !windows && !openbsd && !cgofuse
package main

118
fs_cgo.go Normal file
View File

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

View File

@@ -1,4 +1,4 @@
//go:build windows || openbsd
//go:build openbsd
package main
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
Description: `doesn't work on Windows and OpenBSD.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("this doesn't work on Windows and OpenBSD.")
return fmt.Errorf("this doesn't work on OpenBSD.")
},
}

140
fs_windows.go Normal file
View 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
},
}

241
gift.go
View File

@@ -4,10 +4,14 @@ import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip44"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
@@ -16,19 +20,29 @@ var gift = &cli.Command{
Name: "gift",
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
Description: `example:
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>`,
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "wrap",
Flags: append(
defaultKeyFlags,
Flags: []cli.Flag{
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
),
&cli.BoolFlag{
Name: "use-our-identity-key",
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
},
&cli.BoolFlag{
Name: "use-their-identity-key",
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
},
},
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
Description: `example:
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
@@ -38,14 +52,46 @@ var gift = &cli.Command{
return err
}
recipient := getPubKey(c, "recipient-pubkey")
// get sender pubkey
// get sender pubkey (ourselves)
sender, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get sender pubkey: %w", err)
}
var using bool
var cipher nostr.Cipher = kr
// use decoupled key if it exists
using = false
if !c.Bool("use-our-identity-key") {
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
if has {
if err != nil {
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
}
cipher = keyer.NewPlainKeySigner(eSec)
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
using = true
}
}
if !using {
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
}
recipient := getPubKey(c, "recipient-pubkey")
using = false
if !c.Bool("use-their-identity-key") {
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
recipient = theirEPub
using = true
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
}
}
if !using {
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
}
// read event from stdin
for eventJSON := range getJsonsOrBlank() {
if eventJSON == "{}" {
@@ -65,7 +111,7 @@ var gift = &cli.Command{
// create seal
rumorJSON, _ := easyjson.Marshal(rumor)
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
if err != nil {
return fmt.Errorf("failed to encrypt rumor: %w", err)
}
@@ -114,22 +160,30 @@ var gift = &cli.Command{
Name: "unwrap",
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
Description: `example:
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`,
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
Required: true,
},
),
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
sender := getPubKey(c, "sender-pubkey")
// get receiver public key (ourselves)
receiver, err := kr.GetPublicKey(ctx)
if err != nil {
return err
}
ciphers := []nostr.Cipher{kr}
// use decoupled key if it exists
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
if has {
if err != nil {
return fmt.Errorf("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
for wrapJSON := range getJsonsOrBlank() {
@@ -146,36 +200,79 @@ var gift = &cli.Command{
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
}
ephemeralPubkey := wrap.PubKey
// decrypt seal
sealJSON, err := kr.Decrypt(ctx, wrap.Content, ephemeralPubkey)
if err != nil {
return fmt.Errorf("failed to decrypt seal: %w", err)
}
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
var cipher nostr.Cipher
var seal nostr.Event
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
return fmt.Errorf("invalid seal JSON: %w", err)
// try both the receiver identity key and decoupled key
err = nil
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying 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()))
}
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
if thisErr != nil {
err = thisErr
continue
}
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
continue
}
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID {
// if both ciphers failed above we'll reach here
return fmt.Errorf("failed to decrypt seal: %w", err)
}
if seal.Kind != 13 {
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
}
// decrypt rumor
rumorJSON, err := kr.Decrypt(ctx, seal.Content, sender)
if err != nil {
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
}
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
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)
}
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)
stdout(rumorJSON)
stdout(rumor.String())
}
return nil
@@ -190,3 +287,73 @@ func randomNow() nostr.Timestamp {
randomOffset := rand.Int63n(twoDays)
return nostr.Timestamp(now - randomOffset)
}
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
}
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
eSec, err := nostr.SecretKeyFromHex(string(data))
if err != nil {
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
return eSec, true, nil
}
}
return [32]byte{}, false, nil
}
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return nostr.ZeroPK, false
}
return ePub, true
}
return nostr.ZeroPK, false
}

225
git.go
View File

@@ -181,7 +181,7 @@ aside from those, there is also:
var fetchedRepo *nip34.Repository
if existingConfig.Identifier == "" {
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 {
fetchedRepo = &repo
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
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",
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
config.Identifier,
@@ -367,7 +371,7 @@ aside from those, there is also:
}
// fetch repository metadata and state
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
if err != nil {
return err
}
@@ -455,11 +459,17 @@ aside from those, there is also:
{
Name: "push",
Usage: "push git changes",
Flags: append(defaultKeyFlags, &cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "force push to git remotes",
}),
Flags: append(defaultKeyFlags,
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
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 {
// setup signer
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))
}
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
newStateEvent := state.ToEvent()
err = kr.SignEvent(ctx, &newStateEvent)
@@ -553,6 +597,9 @@ aside from those, there is also:
if c.Bool("force") {
pushArgs = append(pushArgs, "--force")
}
if c.Bool("tags") {
pushArgs = append(pushArgs, "--tags")
}
pushCmd := exec.Command("git", pushArgs...)
pushCmd.Stderr = os.Stderr
pushCmd.Stdout = os.Stdout
@@ -735,6 +782,98 @@ aside from those, there is also:
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
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 {
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
}
@@ -842,33 +981,40 @@ 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)
}
// create a local repository object from config and publish it
localRepo := localConfig.ToRepository()
if signer != nil {
signerPk, err := signer.GetPublicKey(ctx)
if err != nil {
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
}
if signerPk != owner {
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
} else {
event := localRepo.ToEvent()
if err := signer.SignEvent(ctx, &event); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
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
localRepo := localConfig.ToRepository()
if signer != nil {
signerPk, err := signer.GetPublicKey(ctx)
if err != nil {
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
}
for res := range sys.Pool.PublishMany(ctx, relays, event) {
if res.Error != nil {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.RelayURL))
if signerPk != owner {
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
} else {
event = localRepo.ToEvent()
if err := signer.SignEvent(ctx, &event); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
}
}
repo = localRepo
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
repo = localRepo
}
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
if res.Error != nil {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.RelayURL))
}
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
} else {
if err != nil {
@@ -904,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
} else {
log("local configuration is newer, publishing updated repository announcement...\n")
announcementEvent := localRepo.ToEvent()
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
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")
for _, line := range lines {
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])
if dir != "" {
delCmd.Dir = dir
@@ -1078,7 +1225,7 @@ func gitUpdateRefs(ctx context.Context, dir string, state nip34.RepositoryState)
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)
if dir != "" {
updateCmd.Dir = dir
@@ -1091,7 +1238,7 @@ func gitUpdateRefs(ctx context.Context, dir string, state nip34.RepositoryState)
// create ref for HEAD
if state.HEAD != "" {
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)
if dir != "" {
updateCmd.Dir = dir
@@ -1108,7 +1255,7 @@ func fetchRepositoryAndState(
pubkey nostr.PubKey,
identifier 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)
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
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
upToDateRelays = []string{ie.Relay.URL}
upToDateAnnouncementEvent = &ie.Event
} else if ie.Event.CreatedAt == repo.CreatedAt {
// we discard this because it's the same, but this relay is up-to-date
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
}
}
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)
@@ -1165,10 +1314,10 @@ func fetchRepositoryAndState(
}
}
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 }

13
go.mod
View File

@@ -3,8 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -12,7 +11,6 @@ require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/hanwen/go-fuse/v2 v2.7.2
github.com/json-iterator/go v1.1.12
github.com/liamg/magic v0.0.1
github.com/mailru/easyjson v0.9.1
@@ -24,14 +22,21 @@ require (
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
github.com/winfsp/cgofuse v1.6.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/sync v0.18.0
golang.org/x/term v0.32.0
)
require (
fiatjaf.com/lib v0.3.2
github.com/hanwen/go-fuse/v2 v2.9.0
)
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/PowerDNS/lmdb-go v1.9.3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -68,7 +73,6 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -96,7 +100,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect

20
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157 h1:14yLsO2HwpS2CLIKFvLMDp8tVEDahwdC8OeG6NGaL+M=
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4 h1:DF/4NSbCvXqIIRrwYp7L3S0SqC7/IhQl8mHkmYA5uXM=
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/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
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-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -269,6 +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/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -281,8 +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-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=

586
group.go Normal file
View 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
}

View File

@@ -46,8 +46,14 @@ var (
)
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
stat, err := os.Stdin.Stat()
if err != nil {
panic(err)
}
mode := stat.Mode()
is := mode&os.ModeCharDevice == 0
return is
}
func getJsonsOrBlank() iter.Seq[string] {
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if !hasStdin && !isPiped() {
if !hasStdin {
yield("{}")
}
@@ -530,21 +536,25 @@ func decodeTagValue(value string) string {
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
underline func(...any) string
underlinef func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,

13
key.go
View File

@@ -279,12 +279,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
continue
}
sk = data.(nostr.SecretKey)
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
} else {
var err error
sk, err = nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
}
ch <- sk

43
lmdb.go Normal file
View 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
View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"fmt"
"net/http"
"net/textproto"
"os"
@@ -26,9 +25,9 @@ var app = &cli.Command{
Usage: "the nostr army knife command-line tool",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
event,
eventCmd,
req,
filter,
filterCmd,
fetch,
count,
decode,
@@ -40,18 +39,21 @@ var app = &cli.Command{
bunker,
serve,
blossomCmd,
dekey,
encrypt,
decrypt,
gift,
outbox,
outboxCmd,
wallet,
mcpServer,
curl,
fsCmd,
publish,
git,
group,
nip,
syncCmd,
spell,
},
Version: version,
Flags: []cli.Flag{
@@ -62,7 +64,7 @@ var app = &cli.Command{
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return filepath.Join("/dev/null")
return ""
}
})(),
},
@@ -98,9 +100,7 @@ var app = &cli.Command{
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys = sdk.NewSystem()
if err := initializeOutboxHintsDB(c, sys); err != nil {
return ctx, fmt.Errorf("failed to initialize outbox hints: %w", err)
}
setupLocalDatabases(c, sys)
sys.Pool = nostr.NewPool(nostr.PoolOptions{
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,

11
non_lmdb.go Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -3,80 +3,21 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/bbolth"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var (
hintsFilePath string
hintsFileExists bool
)
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
configPath := c.String("config-path")
if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); err == nil {
hintsFileExists = true
} else if !os.IsNotExist(err) {
return err
}
}
if hintsFileExists && hintsFilePath != "" {
hintsdb, err := bbolth.NewBoltHints(hintsFilePath)
if err == nil {
sys.Hints = hintsdb
}
}
return nil
}
var outbox = &cli.Command{
var outboxCmd = &cli.Command{
Name: "outbox",
Usage: "manage outbox relay hints database",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "init",
Usage: "initialize the outbox hints database",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if hintsFileExists {
return nil
}
if hintsFilePath == "" {
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
}
os.MkdirAll(hintsFilePath, 0755)
_, err := bbolth.NewBoltHints(hintsFilePath)
if err != nil {
return fmt.Errorf("failed to create bolt hints db at '%s': %w", hintsFilePath, err)
}
log("initialized hints database at %s\n", hintsFilePath)
return nil
},
},
{
Name: "list",
Usage: "list outbox relays for a given pubkey",
ArgsUsage: "<pubkey>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if !hintsFileExists {
log(color.YellowString("running with temporary fragile data.\n"))
log(color.YellowString("call `nak outbox init` to setup persistence.\n"))
}
if c.Args().Len() != 1 {
return fmt.Errorf("expected exactly one argument (pubkey)")
}

View File

@@ -153,7 +153,7 @@ example:
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
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)
},
},

239
req.go
View File

@@ -9,6 +9,7 @@ import (
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
@@ -77,11 +78,6 @@ example:
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.UintFlag{
Name: "paginate-global-limit",
Usage: "global limit at which --paginate should stop",
DefaultText: "uses the value given by --limit/-l or infinite",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
@@ -96,6 +92,10 @@ example:
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
)...,
),
ArgsUsage: "[relay...]",
@@ -115,7 +115,16 @@ example:
return fmt.Errorf("incompatible flags --paginate and --outbox")
}
if c.Bool("bare") && c.Bool("spell") {
return fmt.Errorf("incompatible flags --bare and --spell")
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
@@ -129,7 +138,7 @@ example:
relayUrls,
forcePreAuthSigner,
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) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
@@ -226,100 +235,29 @@ example:
}
}
} else {
var results chan nostr.RelayEvent
opts := nostr.SubscriptionOptions{
Label: "nak-req",
}
if c.Bool("paginate") {
paginator := sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
results = paginator(ctx, relayUrls, filter, opts)
} else if c.Bool("outbox") {
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
// hardcoded relays, if any
for _, relayUrl := range relayUrls {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: relayUrl,
})
}
// relays for each pubkey
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(c.Uint("outbox-relays-per-pubkey"))
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already hardcoded, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
})
}
errg.Wait()
if c.Bool("stream") {
results = sys.Pool.BatchedSubscribeMany(ctx, defs, opts)
} else {
results = sys.Pool.BatchedQueryMany(ctx, defs, opts)
}
} else {
if c.Bool("stream") {
results = sys.Pool.SubscribeMany(ctx, relayUrls, filter, opts)
} else {
results = sys.Pool.FetchMany(ctx, relayUrls, filter, opts)
}
}
for ie := range results {
stdout(ie.Event)
}
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")
}
} else {
// no relays given, will just print the filter
// no relays given, will just print the filter or spell
var result string
if c.Bool("bare") {
if c.Bool("spell") {
// output a spell event instead of a filter
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
spellEvent := createSpellEvent(ctx, filter, kr)
j, _ := json.Marshal(spellEvent)
result = string(j)
} else if c.Bool("bare") {
// bare filter output
result = filter.String()
} else {
// normal filter
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
}
}
stdout(result)
}
}
@@ -329,6 +267,123 @@ example:
},
}
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)
} else if outbox {
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
for _, relayUrl := range relayUrls {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: relayUrl,
})
}
// relays for each pubkey
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
})
}
errg.Wait()
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
} else {
logverbose("running query with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
}
} else {
if stream {
logverbose("running subscription to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
} else {
logverbose("running query to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
}
}
readevents:
for {
select {
case ie, ok := <-results:
if !ok {
break readevents
}
stdout(ie.Event)
case closed := <-closeds:
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
}
case <-ctx.Done():
break readevents
}
}
}
var reqFilterFlags = []cli.Flag{
&PubKeySliceFlag{
Name: "author",

View File

@@ -51,6 +51,12 @@ var serve = &cli.Command{
Name: "grasp",
Usage: "enable grasp server",
},
&cli.StringFlag{
Name: "grasp-path",
Usage: "where to store the repositories",
TakesFile: true,
Hidden: true,
},
&cli.BoolFlag{
Name: "blossom",
Usage: "enable blossom server",
@@ -135,10 +141,13 @@ var serve = &cli.Command{
}
if c.Bool("grasp") {
var err error
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
if err != nil {
return fmt.Errorf("failed to create grasp repos directory: %w", err)
repoDir = c.String("grasp-path")
if repoDir == "" {
var err error
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
if err != nil {
return fmt.Errorf("failed to create grasp repos directory: %w", err)
}
}
g := grasp.New(rl, repoDir)
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {

547
spell.go Normal file
View 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
}