mirror of
https://github.com/fiatjaf/nak.git
synced 2025-12-23 06:58:51 +00:00
Compare commits
27 Commits
210cf66d5f
...
v0.17.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965a312b46 | ||
|
|
2e4079f92c | ||
|
|
5b64795015 | ||
|
|
5d4fe434c3 | ||
|
|
b95665d986 | ||
|
|
3be80c29df | ||
|
|
e01cfbde47 | ||
|
|
e91d4429ec | ||
|
|
21423b4a21 | ||
|
|
1b7f3162b5 | ||
|
|
8f38468103 | ||
|
|
9bf728d850 | ||
|
|
8396738fe2 | ||
|
|
c1d1682d6e | ||
|
|
6f00ff4c73 | ||
|
|
68bbece3db | ||
|
|
a83b23d76b | ||
|
|
a288cc47a4 | ||
|
|
5ee7670ba8 | ||
|
|
b973b476bc | ||
|
|
252612b12f | ||
|
|
4b8b6bb3de | ||
|
|
df491be232 | ||
|
|
1dab81f77c | ||
|
|
11228d7082 | ||
|
|
a422b5f708 | ||
|
|
852fe6bdfb |
@@ -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)
|
||||
}
|
||||
|
||||
315
dekey.go
Normal file
315
dekey.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"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-name",
|
||||
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"
|
||||
}(),
|
||||
},
|
||||
),
|
||||
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)
|
||||
}
|
||||
|
||||
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},
|
||||
Tags: nostr.TagMap{
|
||||
"pubkey": []string{devicePub.Hex()},
|
||||
},
|
||||
}, 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,
|
||||
Content: "",
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"client", deviceName},
|
||||
{"pubkey", 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 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},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
||||
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()
|
||||
|
||||
// store it
|
||||
eKeyPath := filepath.Join(configPath, "dekey", "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 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: "",
|
||||
CreatedAt: userKeyEventDate,
|
||||
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
|
||||
}
|
||||
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
|
||||
for _, tag := range userKeyEvent.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "n" {
|
||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
if ePub == nostr.ZeroPK {
|
||||
return fmt.Errorf("invalid kind:10044 event, no 'n' tag")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if eSec.Public() != ePub {
|
||||
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},
|
||||
Tags: nostr.TagMap{
|
||||
"p": []string{devicePub.Hex()},
|
||||
},
|
||||
}, 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 decrypted user encryption key from another device\n"))
|
||||
// store it
|
||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if eSec == [32]byte{} {
|
||||
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},
|
||||
Authors: []nostr.PubKey{userPub},
|
||||
Since: userKeyEventDate,
|
||||
}, 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 {
|
||||
continue
|
||||
}
|
||||
|
||||
// assume a key msg will always come before its associated devicemsg
|
||||
// so just store them here:
|
||||
pubkeyTag := keyOrDeviceEvt.Tags.Find("p")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
// if this already has a corresponding keyMsg then skip it
|
||||
pubkeyTag := keyOrDeviceEvt.Tags.Find("pubkey")
|
||||
if pubkeyTag == nil {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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(color.GreenString("encryption key sent to device\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -17,7 +17,7 @@ var encrypt = &cli.Command{
|
||||
defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey"},
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@@ -79,7 +79,7 @@ var decrypt = &cli.Command{
|
||||
defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "sender-pubkey",
|
||||
Aliases: []string{"p", "src", "source", "pubkey"},
|
||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
|
||||
2
event.go
2
event.go
@@ -24,7 +24,7 @@ const (
|
||||
CATEGORY_EXTRAS = "EXTRAS"
|
||||
)
|
||||
|
||||
var event = &cli.Command{
|
||||
var eventCmd = &cli.Command{
|
||||
Name: "event",
|
||||
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
|
||||
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
|
||||
|
||||
@@ -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: `
|
||||
|
||||
192
gift.go
Normal file
192
gift.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip44"
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
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>`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "wrap",
|
||||
Flags: append(
|
||||
defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "recipient-pubkey",
|
||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||
Required: true,
|
||||
},
|
||||
),
|
||||
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>`,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recipient := getPubKey(c, "recipient-pubkey")
|
||||
|
||||
// get sender pubkey
|
||||
sender, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
||||
}
|
||||
|
||||
// read event from stdin
|
||||
for eventJSON := range getJsonsOrBlank() {
|
||||
if eventJSON == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
var originalEvent nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(eventJSON), &originalEvent); err != nil {
|
||||
return fmt.Errorf("invalid event JSON: %w", err)
|
||||
}
|
||||
|
||||
// turn into rumor (unsigned event)
|
||||
rumor := originalEvent
|
||||
rumor.Sig = [64]byte{} // remove signature
|
||||
rumor.PubKey = sender
|
||||
rumor.ID = rumor.GetID() // compute ID
|
||||
|
||||
// create seal
|
||||
rumorJSON, _ := easyjson.Marshal(rumor)
|
||||
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
||||
}
|
||||
seal := &nostr.Event{
|
||||
Kind: 13,
|
||||
Content: encryptedRumor,
|
||||
PubKey: sender,
|
||||
CreatedAt: randomNow(),
|
||||
Tags: nostr.Tags{},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, seal); err != nil {
|
||||
return fmt.Errorf("failed to sign seal: %w", err)
|
||||
}
|
||||
|
||||
// create gift wrap
|
||||
ephemeral := nostr.Generate()
|
||||
sealJSON, _ := easyjson.Marshal(seal)
|
||||
convkey, err := nip44.GenerateConversationKey(recipient, ephemeral)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate conversation key: %w", err)
|
||||
}
|
||||
encryptedSeal, err := nip44.Encrypt(string(sealJSON), convkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt seal: %w", err)
|
||||
}
|
||||
wrap := &nostr.Event{
|
||||
Kind: 1059,
|
||||
Content: encryptedSeal,
|
||||
CreatedAt: randomNow(),
|
||||
Tags: nostr.Tags{{"p", recipient.Hex()}},
|
||||
}
|
||||
wrap.Sign(ephemeral)
|
||||
|
||||
// print the gift-wrap
|
||||
wrapJSON, err := easyjson.Marshal(wrap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal gift wrap: %w", err)
|
||||
}
|
||||
stdout(string(wrapJSON))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
),
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sender := getPubKey(c, "sender-pubkey")
|
||||
|
||||
// read gift-wrapped event from stdin
|
||||
for wrapJSON := range getJsonsOrBlank() {
|
||||
if wrapJSON == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
var wrap nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(wrapJSON), &wrap); err != nil {
|
||||
return fmt.Errorf("invalid gift wrap JSON: %w", err)
|
||||
}
|
||||
|
||||
if wrap.Kind != 1059 {
|
||||
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)
|
||||
}
|
||||
|
||||
var seal nostr.Event
|
||||
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
|
||||
return fmt.Errorf("invalid seal JSON: %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 {
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func randomNow() nostr.Timestamp {
|
||||
const twoDays = 2 * 24 * 60 * 60
|
||||
now := time.Now().Unix()
|
||||
randomOffset := rand.Int63n(twoDays)
|
||||
return nostr.Timestamp(now - randomOffset)
|
||||
}
|
||||
173
git.go
173
git.go
@@ -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))
|
||||
@@ -367,7 +367,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 +455,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 +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,8 +593,12 @@ 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
|
||||
if err := pushCmd.Run(); err != nil {
|
||||
log("! failed to push to %s: %v\n", color.YellowString(remoteName), err)
|
||||
} else {
|
||||
@@ -821,9 +865,26 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
||||
}
|
||||
|
||||
// fetch repository announcement and state from relays
|
||||
repo, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||
if err != nil && repo.Event.ID == nostr.ZeroID {
|
||||
log("couldn't fetch repository metadata (%s), will publish now\n", err)
|
||||
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||
notUpToDate := func(graspServer string) bool {
|
||||
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
||||
}
|
||||
if upToDateRelays == nil || slices.ContainsFunc(localConfig.GraspServers, notUpToDate) {
|
||||
var relays []string
|
||||
if upToDateRelays == nil {
|
||||
// condition 1
|
||||
relays = append(sys.FetchOutboxRelays(ctx, owner, 3), localConfig.GraspServers...)
|
||||
log("couldn't fetch repository metadata (%s), will publish now\n", err)
|
||||
} else {
|
||||
// condition 2
|
||||
relays = make([]string, 0, len(localConfig.GraspServers)-1)
|
||||
for _, gs := range localConfig.GraspServers {
|
||||
if notUpToDate(gs) {
|
||||
relays = append(relays, graspServerHost(gs))
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -840,7 +901,6 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||
}
|
||||
|
||||
relays := append(sys.FetchOutboxRelays(ctx, owner, 3), localConfig.GraspServers...)
|
||||
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)
|
||||
@@ -975,38 +1035,60 @@ func gitSetupRemotes(ctx context.Context, dir string, repo nip34.Repository) {
|
||||
if strings.HasPrefix(remote, "nip34/grasp/") {
|
||||
graspURL := rebuildGraspURLFromRemote(remote)
|
||||
|
||||
getUrlCmd := exec.Command("git", "remote", "get-url", remote)
|
||||
if dir != "" {
|
||||
getUrlCmd.Dir = dir
|
||||
}
|
||||
if output, err := getUrlCmd.Output(); err != nil {
|
||||
panic(fmt.Errorf("failed to read remote (%s) url from git: %s", remote, err))
|
||||
} else {
|
||||
// check if the remote url is correct so we can update it if not
|
||||
gitURL := fmt.Sprintf("http%s/%s/%s.git", nostr.NormalizeURL(graspURL)[2:], nip19.EncodeNpub(repo.PubKey), repo.ID)
|
||||
if strings.TrimSpace(string(output)) != gitURL {
|
||||
goto delete
|
||||
}
|
||||
}
|
||||
|
||||
// check if this remote is not present in our grasp list anymore
|
||||
if !slices.Contains(repo.Relays, nostr.NormalizeURL(graspURL)) {
|
||||
delCmd := exec.Command("git", "remote", "remove", remote)
|
||||
if dir != "" {
|
||||
delCmd.Dir = dir
|
||||
}
|
||||
if err := delCmd.Run(); err != nil {
|
||||
logverbose("failed to remove remote %s: %v\n", remote, err)
|
||||
}
|
||||
goto delete
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
delete:
|
||||
logverbose("deleting remote %s\n", remote)
|
||||
delCmd := exec.Command("git", "remote", "remove", remote)
|
||||
if dir != "" {
|
||||
delCmd.Dir = dir
|
||||
}
|
||||
if err := delCmd.Run(); err != nil {
|
||||
logverbose("failed to remove remote %s: %v\n", remote, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create new remotes for each grasp server
|
||||
remotes = strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, relay := range repo.Relays {
|
||||
remote := gitRemoteName(relay)
|
||||
gitURL := fmt.Sprintf("http%s/%s/%s.git", nostr.NormalizeURL(relay)[2:], nip19.EncodeNpub(repo.PubKey), repo.ID)
|
||||
|
||||
if !slices.Contains(remotes, remote) {
|
||||
// construct the git URL
|
||||
gitURL := fmt.Sprintf("http%s/%s/%s.git",
|
||||
relay[2:], nip19.EncodeNpub(repo.PubKey), repo.ID)
|
||||
if slices.Contains(remotes, remote) {
|
||||
continue
|
||||
}
|
||||
|
||||
addCmd := exec.Command("git", "remote", "add", remote, gitURL)
|
||||
if dir != "" {
|
||||
addCmd.Dir = dir
|
||||
}
|
||||
if out, err := addCmd.Output(); err != nil {
|
||||
var stderr string
|
||||
if exiterr, ok := err.(*exec.ExitError); ok {
|
||||
stderr = string(exiterr.Stderr)
|
||||
}
|
||||
logverbose("failed to add remote %s: %s %s\n", remote, stderr, string(out))
|
||||
logverbose("adding new remote for '%s'\n", relay)
|
||||
addCmd := exec.Command("git", "remote", "add", remote, gitURL)
|
||||
if dir != "" {
|
||||
addCmd.Dir = dir
|
||||
}
|
||||
if out, err := addCmd.Output(); err != nil {
|
||||
var stderr string
|
||||
if exiterr, ok := err.(*exec.ExitError); ok {
|
||||
stderr = string(exiterr.Stderr)
|
||||
}
|
||||
logverbose("failed to add remote %s: %s %s\n", remote, stderr, string(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,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
|
||||
@@ -1039,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
|
||||
@@ -1052,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
|
||||
@@ -1069,7 +1151,7 @@ func fetchRepositoryAndState(
|
||||
pubkey nostr.PubKey,
|
||||
identifier string,
|
||||
relayHints []string,
|
||||
) (repo nip34.Repository, state *nip34.RepositoryState, err error) {
|
||||
) (repo nip34.Repository, 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{
|
||||
@@ -1079,13 +1161,24 @@ func fetchRepositoryAndState(
|
||||
"d": []string{identifier},
|
||||
},
|
||||
Limit: 2,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
|
||||
}, nostr.SubscriptionOptions{
|
||||
Label: "nak-git",
|
||||
CheckDuplicate: func(id nostr.ID, relay string) bool {
|
||||
return false
|
||||
},
|
||||
}) {
|
||||
if ie.Event.CreatedAt > repo.CreatedAt {
|
||||
repo = nip34.ParseRepository(ie.Event)
|
||||
|
||||
// reset this list as the previous was for relays with the older version
|
||||
upToDateRelays = []string{ie.Relay.URL}
|
||||
} 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, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||
}
|
||||
|
||||
// fetch repository state (30618)
|
||||
@@ -1115,10 +1208,10 @@ func fetchRepositoryAndState(
|
||||
}
|
||||
}
|
||||
if stateErr != nil {
|
||||
return repo, state, stateErr
|
||||
return repo, upToDateRelays, state, stateErr
|
||||
}
|
||||
|
||||
return repo, state, nil
|
||||
return repo, upToDateRelays, state, nil
|
||||
}
|
||||
|
||||
type StateErr struct{ string }
|
||||
|
||||
6
go.mod
6
go.mod
@@ -4,10 +4,11 @@ go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.1
|
||||
fiatjaf.com/nostr v0.0.0-20251126101225-44130595c606
|
||||
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
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
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
|
||||
@@ -31,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
|
||||
@@ -41,7 +43,6 @@ require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/glamour v0.10.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // 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
|
||||
|
||||
16
go.sum
16
go.sum
@@ -1,7 +1,7 @@
|
||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
fiatjaf.com/nostr v0.0.0-20251126101225-44130595c606 h1:wQHJ0TFA0Fuq92p/6u6AbsBFq6ZVToSdxV6puXVIruI=
|
||||
fiatjaf.com/nostr v0.0.0-20251126101225-44130595c606/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=
|
||||
@@ -13,12 +13,18 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe
|
||||
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
|
||||
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
@@ -65,6 +71,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
@@ -138,6 +146,8 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh
|
||||
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -271,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
43
lmdb.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build linux && !riscv64 && !arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fiatjaf.com/nostr/eventstore/lmdb"
|
||||
"fiatjaf.com/nostr/eventstore/nullstore"
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"fiatjaf.com/nostr/sdk/hints/lmdbh"
|
||||
lmdbkv "fiatjaf.com/nostr/sdk/kvstore/lmdb"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
configPath := c.String("config-path")
|
||||
if configPath != "" {
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
_, err := lmdbh.NewLMDBHints(hintsPath)
|
||||
if err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
}
|
||||
}
|
||||
17
main.go
17
main.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
@@ -26,9 +25,9 @@ var app = &cli.Command{
|
||||
Usage: "the nostr army knife command-line tool",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
event,
|
||||
eventCmd,
|
||||
req,
|
||||
filter,
|
||||
filterCmd,
|
||||
fetch,
|
||||
count,
|
||||
decode,
|
||||
@@ -40,9 +39,11 @@ var app = &cli.Command{
|
||||
bunker,
|
||||
serve,
|
||||
blossomCmd,
|
||||
dekey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
outbox,
|
||||
gift,
|
||||
outboxCmd,
|
||||
wallet,
|
||||
mcpServer,
|
||||
curl,
|
||||
@@ -50,6 +51,8 @@ var app = &cli.Command{
|
||||
publish,
|
||||
git,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
@@ -60,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 ""
|
||||
}
|
||||
})(),
|
||||
},
|
||||
@@ -96,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
11
non_lmdb.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux || riscv64 || arm64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
}
|
||||
61
outbox.go
61
outbox.go
@@ -3,80 +3,21 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fiatjaf.com/nostr/sdk"
|
||||
"fiatjaf.com/nostr/sdk/hints/bbolth"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
hintsFilePath string
|
||||
hintsFileExists bool
|
||||
)
|
||||
|
||||
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
|
||||
configPath := c.String("config-path")
|
||||
if configPath != "" {
|
||||
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
|
||||
}
|
||||
if hintsFilePath != "" {
|
||||
if _, err := os.Stat(hintsFilePath); err == nil {
|
||||
hintsFileExists = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hintsFileExists && hintsFilePath != "" {
|
||||
hintsdb, err := bbolth.NewBoltHints(hintsFilePath)
|
||||
if err == nil {
|
||||
sys.Hints = hintsdb
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var outbox = &cli.Command{
|
||||
var outboxCmd = &cli.Command{
|
||||
Name: "outbox",
|
||||
Usage: "manage outbox relay hints database",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "init",
|
||||
Usage: "initialize the outbox hints database",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if hintsFileExists {
|
||||
return nil
|
||||
}
|
||||
if hintsFilePath == "" {
|
||||
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
|
||||
}
|
||||
|
||||
os.MkdirAll(hintsFilePath, 0755)
|
||||
_, err := bbolth.NewBoltHints(hintsFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bolt hints db at '%s': %w", hintsFilePath, err)
|
||||
}
|
||||
|
||||
log("initialized hints database at %s\n", hintsFilePath)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "list outbox relays for a given pubkey",
|
||||
ArgsUsage: "<pubkey>",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if !hintsFileExists {
|
||||
log(color.YellowString("running with temporary fragile data.\n"))
|
||||
log(color.YellowString("call `nak outbox init` to setup persistence.\n"))
|
||||
}
|
||||
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("expected exactly one argument (pubkey)")
|
||||
}
|
||||
|
||||
207
req.go
207
req.go
@@ -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,89 +222,7 @@ 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
|
||||
@@ -329,6 +243,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",
|
||||
|
||||
17
serve.go
17
serve.go
@@ -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) {
|
||||
|
||||
473
spell.go
Normal file
473
spell.go
Normal 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,
|
||||
)
|
||||
}
|
||||
464
sync.go
Normal file
464
sync.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip77"
|
||||
"fiatjaf.com/nostr/nip77/negentropy"
|
||||
"fiatjaf.com/nostr/nip77/negentropy/storage"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var syncCmd = &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "sync events between two relays using negentropy",
|
||||
Description: `uses nip77 negentropy to sync events between two relays`,
|
||||
ArgsUsage: "<relay1> <relay2>",
|
||||
Flags: reqFilterFlags,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("need exactly two relay URLs: source and target")
|
||||
}
|
||||
|
||||
filter := nostr.Filter{}
|
||||
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
peerA, err := NewRelayThirdPartyRemote(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting up %s: %w", args[0], err)
|
||||
}
|
||||
|
||||
peerB, err := NewRelayThirdPartyRemote(ctx, args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting up %s: %w", args[1], err)
|
||||
}
|
||||
|
||||
tpn := NewThirdPartyNegentropy(
|
||||
peerA,
|
||||
peerB,
|
||||
filter,
|
||||
)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Go(func() {
|
||||
err = tpn.Run(ctx)
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
type op struct {
|
||||
src *nostr.Relay
|
||||
dst *nostr.Relay
|
||||
ids []nostr.ID
|
||||
}
|
||||
|
||||
pending := []op{
|
||||
{peerA.relay, peerB.relay, make([]nostr.ID, 0, 30)},
|
||||
{peerB.relay, peerA.relay, make([]nostr.ID, 0, 30)},
|
||||
}
|
||||
|
||||
for delta := range tpn.Deltas {
|
||||
have := delta.Have.relay
|
||||
havenot := delta.HaveNot.relay
|
||||
logverbose("%s has %s, %s doesn't.\n", have.URL, delta.ID.Hex(), havenot.URL)
|
||||
|
||||
idx := 0 // peerA
|
||||
if have == peerB.relay {
|
||||
idx = 1 // peerB
|
||||
}
|
||||
pending[idx].ids = append(pending[idx].ids, delta.ID)
|
||||
|
||||
// every 30 ids do a fetch-and-publish
|
||||
if len(pending[idx].ids) == 30 {
|
||||
for evt := range pending[idx].src.QueryEvents(nostr.Filter{IDs: pending[idx].ids}) {
|
||||
pending[idx].dst.Publish(ctx, evt)
|
||||
}
|
||||
pending[idx].ids = pending[idx].ids[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// do it for the remaining ids
|
||||
for _, op := range pending {
|
||||
if len(op.ids) > 0 {
|
||||
for evt := range op.src.QueryEvents(nostr.Filter{IDs: op.ids}) {
|
||||
op.dst.Publish(ctx, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
type ThirdPartyNegentropy struct {
|
||||
PeerA *RelayThirdPartyRemote
|
||||
PeerB *RelayThirdPartyRemote
|
||||
Filter nostr.Filter
|
||||
|
||||
Deltas chan Delta
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
ID nostr.ID
|
||||
Have *RelayThirdPartyRemote
|
||||
HaveNot *RelayThirdPartyRemote
|
||||
}
|
||||
|
||||
type boundKey string
|
||||
|
||||
func getBoundKey(b negentropy.Bound) boundKey {
|
||||
return boundKey(fmt.Sprintf("%d:%x", b.Timestamp, b.IDPrefix))
|
||||
}
|
||||
|
||||
type RelayThirdPartyRemote struct {
|
||||
relay *nostr.Relay
|
||||
messages chan string
|
||||
err error
|
||||
}
|
||||
|
||||
func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdPartyRemote, error) {
|
||||
rtpr := &RelayThirdPartyRemote{
|
||||
messages: make(chan string, 3),
|
||||
}
|
||||
|
||||
var err error
|
||||
rtpr.relay, err = nostr.RelayConnect(ctx, url, nostr.RelayOptions{
|
||||
CustomHandler: func(data string) {
|
||||
envelope := nip77.ParseNegMessage(data)
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
switch env := envelope.(type) {
|
||||
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
|
||||
rtpr.err = fmt.Errorf("unexpected %s received from relay", env.Label())
|
||||
return
|
||||
case *nip77.ErrorEnvelope:
|
||||
rtpr.err = fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason)
|
||||
return
|
||||
case *nip77.MessageEnvelope:
|
||||
rtpr.messages <- env.Message
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rtpr, nil
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendInitialMessage(filter nostr.Filter, msg string) error {
|
||||
msgj, _ := json.Marshal(nip77.OpenEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
Filter: filter,
|
||||
Message: msg,
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendMessage(msg string) error {
|
||||
msgj, _ := json.Marshal(nip77.MessageEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
Message: msg,
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) SendClose() error {
|
||||
msgj, _ := json.Marshal(nip77.CloseEnvelope{
|
||||
SubscriptionID: "sync3",
|
||||
})
|
||||
return rtpr.relay.WriteWithError(msgj)
|
||||
}
|
||||
|
||||
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
|
||||
|
||||
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
|
||||
if rtpr.err != nil {
|
||||
return "", rtpr.err
|
||||
}
|
||||
if msg, ok := <-rtpr.messages; ok {
|
||||
return msg, nil
|
||||
}
|
||||
return "", thirdPartyRemoteEndOfMessages
|
||||
}
|
||||
|
||||
func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.Filter) *ThirdPartyNegentropy {
|
||||
return &ThirdPartyNegentropy{
|
||||
PeerA: peerA,
|
||||
PeerB: peerB,
|
||||
Filter: filter,
|
||||
Deltas: make(chan Delta, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
|
||||
peerAIds := make(map[nostr.ID]struct{})
|
||||
peerBIds := make(map[nostr.ID]struct{})
|
||||
peerASkippedBounds := make(map[boundKey]struct{})
|
||||
peerBSkippedBounds := make(map[boundKey]struct{})
|
||||
|
||||
// send an empty message to A to start things up
|
||||
initialMsg := createInitialMessage()
|
||||
err := n.PeerA.SendInitialMessage(n.Filter, initialMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSentInitialMessageToB := false
|
||||
|
||||
for {
|
||||
// receive message from A
|
||||
msgA, err := n.PeerA.Receive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgAb, _ := nostr.HexDecodeString(msgA)
|
||||
if len(msgAb) == 1 {
|
||||
break
|
||||
}
|
||||
|
||||
msgToB, err := parseMessageBuildNext(
|
||||
msgA,
|
||||
peerBSkippedBounds,
|
||||
func(id nostr.ID) {
|
||||
if _, exists := peerBIds[id]; exists {
|
||||
delete(peerBIds, id)
|
||||
} else {
|
||||
peerAIds[id] = struct{}{}
|
||||
}
|
||||
},
|
||||
func(boundKey boundKey) {
|
||||
peerASkippedBounds[boundKey] = struct{}{}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// emit deltas from B after receiving message from A
|
||||
for id := range peerBIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
delete(peerBIds, id)
|
||||
}
|
||||
|
||||
if len(msgToB) == 2 {
|
||||
// exit condition (no more messages to send)
|
||||
break
|
||||
}
|
||||
|
||||
// send message to B
|
||||
if hasSentInitialMessageToB {
|
||||
err = n.PeerB.SendMessage(msgToB)
|
||||
} else {
|
||||
err = n.PeerB.SendInitialMessage(n.Filter, msgToB)
|
||||
hasSentInitialMessageToB = true
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// receive message from B
|
||||
msgB, err := n.PeerB.Receive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgBb, _ := nostr.HexDecodeString(msgB)
|
||||
if len(msgBb) == 1 {
|
||||
break
|
||||
}
|
||||
|
||||
msgToA, err := parseMessageBuildNext(
|
||||
msgB,
|
||||
peerASkippedBounds,
|
||||
func(id nostr.ID) {
|
||||
if _, exists := peerAIds[id]; exists {
|
||||
delete(peerAIds, id)
|
||||
} else {
|
||||
peerBIds[id] = struct{}{}
|
||||
}
|
||||
},
|
||||
func(boundKey boundKey) {
|
||||
peerBSkippedBounds[boundKey] = struct{}{}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// emit deltas from A after receiving message from B
|
||||
for id := range peerAIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
delete(peerAIds, id)
|
||||
}
|
||||
|
||||
if len(msgToA) == 2 {
|
||||
// exit condition (no more messages to send)
|
||||
break
|
||||
}
|
||||
|
||||
// send message to A
|
||||
err = n.PeerA.SendMessage(msgToA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// emit remaining deltas before exit
|
||||
for id := range peerAIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
for id := range peerBIds {
|
||||
select {
|
||||
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
n.PeerA.SendClose()
|
||||
n.PeerB.SendClose()
|
||||
close(n.Deltas)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createInitialMessage() string {
|
||||
output := bytes.NewBuffer(make([]byte, 0, 64))
|
||||
output.WriteByte(negentropy.ProtocolVersion)
|
||||
|
||||
dummy := negentropy.BoundWriter{}
|
||||
dummy.WriteBound(output, negentropy.InfiniteBound)
|
||||
output.WriteByte(byte(negentropy.FingerprintMode))
|
||||
|
||||
// hardcoded random fingerprint
|
||||
fingerprint := [negentropy.FingerprintSize]byte{
|
||||
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
|
||||
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
|
||||
}
|
||||
output.Write(fingerprint[:])
|
||||
|
||||
return nostr.HexEncodeToString(output.Bytes())
|
||||
}
|
||||
|
||||
func parseMessageBuildNext(
|
||||
msg string,
|
||||
skippedBounds map[boundKey]struct{},
|
||||
idCallback func(id nostr.ID),
|
||||
skipCallback func(boundKey boundKey),
|
||||
) (string, error) {
|
||||
msgb, err := nostr.HexDecodeString(msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
br := &negentropy.BoundReader{}
|
||||
bw := &negentropy.BoundWriter{}
|
||||
|
||||
nextMsg := bytes.NewBuffer(make([]byte, 0, len(msgb)))
|
||||
acc := &storage.Accumulator{} // this will be used for building our own fingerprints and also as a placeholder
|
||||
|
||||
reader := bytes.NewReader(msgb)
|
||||
pv, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pv != negentropy.ProtocolVersion {
|
||||
return "", fmt.Errorf("unsupported protocol version %v", pv)
|
||||
}
|
||||
|
||||
nextMsg.WriteByte(pv)
|
||||
|
||||
for reader.Len() > 0 {
|
||||
bound, err := br.ReadBound(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
modeVal, err := negentropy.ReadVarInt(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := negentropy.Mode(modeVal)
|
||||
|
||||
switch mode {
|
||||
case negentropy.SkipMode:
|
||||
skipCallback(getBoundKey(bound))
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.SkipMode))
|
||||
}
|
||||
|
||||
case negentropy.FingerprintMode:
|
||||
_, err = reader.Read(acc.Buf[0:negentropy.FingerprintSize] /* use this buffer as a dummy */)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
|
||||
}
|
||||
case negentropy.IdListMode:
|
||||
// when receiving an idlist we will never send this bound again to this peer
|
||||
skipCallback(getBoundKey(bound))
|
||||
|
||||
// and instead of sending these ids to the other peer we'll send a fingerprint
|
||||
acc.Reset()
|
||||
|
||||
numIds, err := negentropy.ReadVarInt(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for range numIds {
|
||||
id := nostr.ID{}
|
||||
|
||||
_, err = reader.Read(id[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
idCallback(id)
|
||||
|
||||
acc.AddBytes(id[:])
|
||||
}
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
fingerprint := acc.GetFingerprint(numIds)
|
||||
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
nextMsg.Write(fingerprint[:])
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown mode %v", mode)
|
||||
}
|
||||
}
|
||||
|
||||
return nostr.HexEncodeToString(nextMsg.Bytes()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user