Compare commits

..

20 Commits

Author SHA1 Message Date
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
14 changed files with 834 additions and 177 deletions

File diff suppressed because one or more lines are too long

View File

@@ -366,6 +366,7 @@ var bunker = &cli.Command{
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
defer handlerWg.Done()
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
@@ -375,7 +376,6 @@ var bunker = &cli.Command{
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}

View File

@@ -9,6 +9,7 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip44"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -30,11 +31,13 @@ var dekey = &cli.Command{
},
),
Action: func(ctx context.Context, c *cli.Command) error {
log(color.CyanString("gathering keyer from arguments...\n"))
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
log(color.CyanString("getting user public key...\n"))
userPub, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
@@ -43,32 +46,40 @@ var dekey = &cli.Command{
configPath := c.String("config-path")
deviceName := c.String("device-name")
log(color.YellowString("handling device key for %s...\n"), deviceName)
// 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(color.CyanString("fetching write relays for user...\n"))
relays := sys.FetchWriteRelays(ctx, userPub)
log(color.CyanString("connecting to %d relays...\n"), len(relays))
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}
log(color.GreenString("connected to %d relays\n"), len(relayList))
// check if kind:4454 is already published
log(color.CyanString("checking for existing device registration (kind:4454)...\n"))
events := sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4454},
Authors: []nostr.PubKey{userPub},
@@ -77,6 +88,7 @@ var dekey = &cli.Command{
},
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
if len(events) == 0 {
log(color.YellowString("no device registration found, publishing kind:4454...\n"))
// publish kind:4454
evt := nostr.Event{
Kind: 4454,
@@ -97,9 +109,13 @@ var dekey = &cli.Command{
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
return err
}
log(color.GreenString("device registration published\n"))
} else {
log(color.GreenString("device already registered\n"))
}
// check for kind:10044
log(color.CyanString("checking for user encryption key (kind:10044)...\n"))
userKeyEventDate := nostr.Now()
userKeyResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
@@ -108,6 +124,7 @@ var dekey = &cli.Command{
var eSec nostr.SecretKey
var ePub nostr.PubKey
if userKeyEvent, ok := userKeyResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""}); !ok {
log(color.YellowString("no user encryption key found, generating new one...\n"))
// generate main secret key
eSec = nostr.Generate()
ePub := eSec.Public()
@@ -118,8 +135,10 @@ var dekey = &cli.Command{
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
return fmt.Errorf("failed to write user encryption key: %w", err)
}
log(color.GreenString("user encryption key generated and stored\n"))
// publish kind:10044
log(color.YellowString("publishing user encryption key (kind:10044)...\n"))
evt10044 := nostr.Event{
Kind: 10044,
Content: "",
@@ -135,7 +154,9 @@ var dekey = &cli.Command{
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
return err
}
log(color.GreenString("user encryption key published\n"))
} else {
log(color.GreenString("found existing user encryption key\n"))
userKeyEventDate = userKeyEvent.CreatedAt
// get the pub from the tag
@@ -152,6 +173,7 @@ var dekey = &cli.Command{
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
log(color.GreenString("found stored user encryption key\n"))
eSec, err = nostr.SecretKeyFromHex(string(data))
if err != nil {
return fmt.Errorf("invalid main key: %w", err)
@@ -160,6 +182,7 @@ var dekey = &cli.Command{
return fmt.Errorf("stored user encryption key is corrupted: %w", err)
}
} else {
log(color.YellowString("user encryption key not stored locally, attempting to decrypt from other devices...\n"))
// try to decrypt from kind:4455
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4455},
@@ -191,6 +214,7 @@ var dekey = &cli.Command{
}
// check if it matches mainPub
if eSec.Public() == ePub {
log(color.GreenString("successfully decrypted user encryption key from another device\n"))
// store it
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
@@ -201,11 +225,13 @@ var dekey = &cli.Command{
}
if eSec == [32]byte{} {
log("main secret key not available, must authorize on another device\n")
log(color.RedString("main secret key not available, must authorize on another device\n"))
return nil
}
log(color.GreenString("user encryption key ready\n"))
// now we have mainSec, check for other kind:4454 events newer than the 10044
log(color.CyanString("checking for other devices and key messages...\n"))
keyMsgs := make([]string, 0, 5)
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4454, 4455},
@@ -214,6 +240,7 @@ var dekey = &cli.Command{
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
if keyOrDeviceEvt.Kind == 4455 {
// key event
log(color.BlueString("received key message (kind:4455)\n"))
// skip ourselves
if keyOrDeviceEvt.Tags.FindWithValue("p", devicePub.Hex()) != nil {
@@ -229,6 +256,7 @@ var dekey = &cli.Command{
keyMsgs = append(keyMsgs, pubkeyTag[1])
} else if keyOrDeviceEvt.Kind == 4454 {
// device event
log(color.BlueString("received device registration (kind:4454)\n"))
// skip ourselves
if keyOrDeviceEvt.Tags.FindWithValue("pubkey", devicePub.Hex()) != nil {
@@ -246,6 +274,7 @@ var dekey = &cli.Command{
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
// so we have to build a keyMsg for them
log(color.YellowString("sending encryption key to new device...\n"))
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
if err != nil {
continue
@@ -273,7 +302,11 @@ var dekey = &cli.Command{
continue
}
publishFlow(ctx, c, kr, evt4455, relayList)
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
log(color.RedString("failed to publish key message: %v\n"), err)
} else {
log(color.GreenString("encryption key sent to device\n"))
}
}
}

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.

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: `

53
git.go
View File

@@ -455,11 +455,17 @@ aside from those, there is also:
{
Name: "push",
Usage: "push git changes",
Flags: append(defaultKeyFlags, &cli.BoolFlag{
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 +532,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 +593,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
@@ -1061,7 +1104,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 +1121,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 +1134,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

4
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -32,6 +32,7 @@ require (
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
@@ -96,7 +97,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

6
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/nostr v0.0.0-20251222025842-099569ea4feb h1:GuqPn1g0JRD/dGxFRxEwEFxvbcT3vyvMjP3OoeLIIh0=
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb/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=
@@ -281,8 +281,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=

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
}
}
}

14
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,
@@ -44,7 +43,7 @@ var app = &cli.Command{
encrypt,
decrypt,
gift,
outbox,
outboxCmd,
wallet,
mcpServer,
curl,
@@ -53,6 +52,7 @@ var app = &cli.Command{
git,
nip,
syncCmd,
spell,
},
Version: version,
Flags: []cli.Flag{
@@ -63,7 +63,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 ""
}
})(),
},
@@ -99,9 +99,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) {
}

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)")
}

103
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",
@@ -226,18 +222,51 @@ example:
}
}
} else {
var results chan nostr.RelayEvent
opts := nostr.SubscriptionOptions{
Label: "nak-req",
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
var result string
if c.Bool("bare") {
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
}
if c.Bool("paginate") {
paginator := sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
stdout(result)
}
}
exitIfLineProcessingError(ctx)
return nil
},
}
func performReq(
ctx context.Context,
filter nostr.Filter,
relayUrls []string,
stream bool,
outbox bool,
outboxRelaysPerPubKey uint64,
paginate bool,
paginateInterval time.Duration,
label string,
) {
var results chan nostr.RelayEvent
var closeds chan nostr.RelayClosed
opts := nostr.SubscriptionOptions{
Label: label,
}
if paginate {
paginator := sys.Pool.PaginatorWithInterval(paginateInterval)
results = paginator(ctx, relayUrls, filter, opts)
} else if c.Bool("outbox") {
} else if 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,
@@ -249,12 +278,13 @@ example:
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(c.Uint("outbox-relays-per-pubkey"))
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already hardcoded, ignore
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
@@ -293,40 +323,41 @@ example:
}
errg.Wait()
if c.Bool("stream") {
results = sys.Pool.BatchedSubscribeMany(ctx, defs, opts)
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
} else {
results = sys.Pool.BatchedQueryMany(ctx, defs, opts)
logverbose("running query with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
}
} else {
if c.Bool("stream") {
results = sys.Pool.SubscribeMany(ctx, relayUrls, filter, opts)
if stream {
logverbose("running subscription to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
} else {
results = sys.Pool.FetchMany(ctx, relayUrls, filter, opts)
logverbose("running query to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
}
}
for ie := range results {
readevents:
for {
select {
case ie, ok := <-results:
if !ok {
break readevents
}
stdout(ie.Event)
}
}
case closed := <-closeds:
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
// no relays given, will just print the filter
var result string
if c.Bool("bare") {
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
}
stdout(result)
case <-ctx.Done():
break readevents
}
}
exitIfLineProcessingError(ctx)
return nil
},
}
var reqFilterFlags = []cli.Flag{

473
spell.go Normal file
View File

@@ -0,0 +1,473 @@
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 {
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,
)
}