Compare commits

..

12 Commits

15 changed files with 1254 additions and 350 deletions

View File

@@ -147,7 +147,7 @@ type the password to decrypt your secret key: **********
### talk to a relay's NIP-86 management API
```shell
nak relay allowpubkey --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls --pubkey a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208 pyramid.fiatjaf.com
nak admin allowpubkey --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls --pubkey a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208 pyramid.fiatjaf.com
type the password to decrypt your secret key: **********
calling 'allowpubkey' on https://pyramid.fiatjaf.com...
{

186
admin.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip86"
"github.com/urfave/cli/v3"
)
var admin = &cli.Command{
Name: "admin",
Usage: "manage relays using the relay management API",
Description: `examples:
nak admin allowpubkey myrelay.com --pubkey 1234... --reason "good user"
nak admin banpubkey myrelay.com --pubkey 1234... --reason "spam"
nak admin listallowedpubkeys myrelay.com
nak admin changerelayname myrelay.com --name "My Relay"`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: (func() []*cli.Command {
methods := []struct {
method string
args []string
}{
{"allowpubkey", []string{"pubkey", "reason"}},
{"banpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"listbannedpubkeys", nil},
{"listeventsneedingmoderation", nil},
{"allowevent", []string{"id", "reason"}},
{"banevent", []string{"id", "reason"}},
{"listbannedevents", nil},
{"changerelayname", []string{"name"}},
{"changerelaydescription", []string{"description"}},
{"changerelayicon", []string{"icon"}},
{"allowkind", []string{"kind"}},
{"disallowkind", []string{"kind"}},
{"listallowedkinds", nil},
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
}
commands := make([]*cli.Command, 0, len(methods))
for _, def := range methods {
def := def
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
for i, argName := range def.args {
flags[i] = declareFlag(argName)
}
cmd := &cli.Command{
Name: def.method,
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
Description: fmt.Sprintf(
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
Flags: flags,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
params := make([]any, len(def.args))
for i, argName := range def.args {
params[i] = getArgument(c, argName)
}
req := nip86.Request{Method: def.method, Params: params}
reqj, _ := json.Marshal(req)
relayUrls := c.Args().Slice()
if len(relayUrls) == 0 {
stdout(string(reqj))
return nil
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
for _, relayUrl := range relayUrls {
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
log("calling '%s' on %s... ", def.method, httpUrl)
body := bytes.NewBuffer(nil)
body.Write(reqj)
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Authorization
payloadHash := sha256.Sum256(reqj)
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return fmt.Errorf("failed to sign token event: %w", err)
}
evtj, _ := json.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
// Content-Type
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// make request to relay
resp, err := http.DefaultClient.Do(req)
if err != nil {
log("failed: %s\n", err)
continue
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log("failed to read response: %s\n", err)
continue
}
if resp.StatusCode >= 300 {
log("failed with status %d\n", resp.StatusCode)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
var response nip86.Response
if err := json.Unmarshal(b, &response); err != nil {
log("bad json response: %s\n", err)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
pretty, _ := json.MarshalIndent(response, "", " ")
stdout(string(pretty))
}
return nil
},
}
commands = append(commands, cmd)
}
return commands
})(),
}
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
return c.Int(argName)
default:
return c.String(argName)
}
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"os"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
@@ -38,11 +37,11 @@ var blossomCmd = &cli.Command{
var client *blossom.Client
pubkey := c.Args().First()
if pubkey != "" {
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
pk, err := parsePubKey(pubkey)
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
} else {
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
}
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)

View File

@@ -21,7 +21,7 @@ var count = &cli.Command{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
@@ -101,16 +101,16 @@ var count = &cli.Command{
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)

View File

@@ -163,7 +163,7 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
id, err := nostr.IDFromHex(target)
id, err := parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue

View File

@@ -211,24 +211,30 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
if len(tagValues) >= 1 {
tagValues[0] = decodeTagValue(tagValues[0])
}
tag = append(tag, tagValues...)
}
tags = append(tags, tag)
}
for _, etag := range c.StringSlice("e") {
if tags.FindWithValue("e", etag) == nil {
tags = append(tags, nostr.Tag{"e", etag})
decodedEtag := decodeTagValue(etag)
if tags.FindWithValue("e", decodedEtag) == nil {
tags = append(tags, nostr.Tag{"e", decodedEtag})
}
}
for _, ptag := range c.StringSlice("p") {
if tags.FindWithValue("p", ptag) == nil {
tags = append(tags, nostr.Tag{"p", ptag})
decodedPtag := decodeTagValue(ptag)
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
decodedDtag := decodeTagValue(dtag)
if tags.FindWithValue("d", decodedDtag) == nil {
tags = append(tags, nostr.Tag{"d", decodedDtag})
}
}
if len(tags) > 0 {
@@ -403,13 +409,20 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
}
} else {
// normal dumb flow
for _, relay := range relays {
for i, relay := range relays {
publish:
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
log("publishing to %s... ", color.CyanString(cleanUrl))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if !relay.IsConnected() {
if new_, err := sys.Pool.EnsureRelay(relay.URL); err == nil {
relays[i] = new_
relay = new_
}
}
err := relay.Publish(ctx, evt)
if err == nil {
// published fine

View File

@@ -96,8 +96,8 @@ func (t pubkeyValue) Create(val nostr.PubKey, p *nostr.PubKey, c struct{}) cli.V
func (t pubkeyValue) ToString(b nostr.PubKey) string { return t.pubkey.String() }
func (t *pubkeyValue) Set(value string) error {
pk, err := nostr.PubKeyFromHex(value)
t.pubkey = pk
pubkey, err := parsePubKey(value)
t.pubkey = pubkey
t.hasBeenSet = true
return err
}
@@ -147,8 +147,8 @@ func (t idValue) Create(val nostr.ID, p *nostr.ID, c struct{}) cli.Value {
func (t idValue) ToString(b nostr.ID) string { return t.id.String() }
func (t *idValue) Set(value string) error {
pk, err := nostr.IDFromHex(value)
t.id = pk
id, err := parseEventID(value)
t.id = id
t.hasBeenSet = true
return err
}

737
git.go Normal file
View File

@@ -0,0 +1,737 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip34"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
type Nip34Config struct {
Identifier string `json:"identifier"`
Name string `json:"name"`
Description string `json:"description"`
Web []string `json:"web"`
Owner string `json:"owner"`
GraspServers []string `json:"grasp-servers"`
EarliestUniqueCommit string `json:"earliest-unique-commit"`
Maintainers []string `json:"maintainers"`
}
var git = &cli.Command{
Name: "git",
Usage: "git-related operations",
Commands: []*cli.Command{
gitInit,
gitPush,
gitPull,
gitFetch,
gitAnnounce,
},
}
var gitInit = &cli.Command{
Name: "init",
Usage: "initialize a NIP-34 repository configuration",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "interactive",
Aliases: []string{"i"},
Usage: "prompt for repository details interactively",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "overwrite existing nip34.json file",
},
&cli.StringFlag{
Name: "identifier",
Usage: "unique identifier for the repository",
},
&cli.StringFlag{
Name: "name",
Usage: "repository name",
},
&cli.StringFlag{
Name: "description",
Usage: "repository description",
},
&cli.StringSliceFlag{
Name: "web",
Usage: "web URLs for the repository (can be used multiple times)",
},
&cli.StringFlag{
Name: "owner",
Usage: "owner public key",
},
&cli.StringSliceFlag{
Name: "grasp-servers",
Usage: "grasp servers (can be used multiple times)",
},
&cli.StringSliceFlag{
Name: "relays",
Usage: "relay URLs to publish to (can be used multiple times)",
},
&cli.StringSliceFlag{
Name: "maintainers",
Usage: "maintainer public keys as npub or hex (can be used multiple times)",
},
&cli.StringFlag{
Name: "earliest-unique-commit",
Usage: "earliest unique commit of the repository",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
// check if current directory is a git repository
cmd := exec.Command("git", "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
return fmt.Errorf("current directory is not a git repository")
}
// check if nip34.json already exists
configPath := "nip34.json"
var existingConfig Nip34Config
if data, err := os.ReadFile(configPath); err == nil {
// file exists, read it
if !c.Bool("force") && !c.Bool("interactive") {
return fmt.Errorf("nip34.json already exists, use --force to overwrite or --interactive to update")
}
if err := json.Unmarshal(data, &existingConfig); err != nil {
log("Warning: could not parse existing nip34.json: %v\n", err)
existingConfig = Nip34Config{}
}
}
// get repository base directory name for defaults
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
baseName := filepath.Base(cwd)
// get earliest unique commit
var earliestCommit string
if output, err := exec.Command("git", "rev-list", "--max-parents=0", "HEAD").Output(); err == nil {
earliest := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(earliest) > 0 {
earliestCommit = earliest[0]
}
}
// extract clone URLs from nostr:// git remotes
var defaultCloneURLs []string
if output, err := exec.Command("git", "remote", "-v").Output(); err == nil {
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, remote := range remotes {
if strings.Contains(remote, "nostr://") {
parts := strings.Fields(remote)
if len(parts) >= 2 {
nostrURL := parts[1]
// parse nostr://npub.../relay_hostname/identifier
if strings.HasPrefix(nostrURL, "nostr://") {
urlParts := strings.TrimPrefix(nostrURL, "nostr://")
components := strings.Split(urlParts, "/")
if len(components) == 3 {
npub := components[0]
relayHostname := components[1]
identifier := components[2]
// convert to https://relay_hostname/npub.../identifier.git
cloneURL := fmt.Sprintf("http%s/%s/%s.git", nostr.NormalizeURL(relayHostname)[2:], npub, identifier)
defaultCloneURLs = appendUnique(defaultCloneURLs, cloneURL)
}
}
}
}
}
}
// helper to get value from flags, existing config, or default
getValue := func(existingVal, flagVal, defaultVal string) string {
if flagVal != "" {
return flagVal
}
if existingVal != "" {
return existingVal
}
return defaultVal
}
getSliceValue := func(existingVals, flagVals, defaultVals []string) []string {
if len(flagVals) > 0 {
return flagVals
}
if len(existingVals) > 0 {
return existingVals
}
return defaultVals
}
config := Nip34Config{
Identifier: getValue(existingConfig.Identifier, c.String("identifier"), baseName),
Name: getValue(existingConfig.Name, c.String("name"), baseName),
Description: getValue(existingConfig.Description, c.String("description"), ""),
Web: getSliceValue(existingConfig.Web, c.StringSlice("web"), []string{}),
Owner: getValue(existingConfig.Owner, c.String("owner"), ""),
GraspServers: getSliceValue(existingConfig.GraspServers, c.StringSlice("grasp-servers"), []string{"gitnostr.com", "relay.ngit.dev"}),
EarliestUniqueCommit: getValue(existingConfig.EarliestUniqueCommit, c.String("earliest-unique-commit"), earliestCommit),
Maintainers: getSliceValue(existingConfig.Maintainers, c.StringSlice("maintainers"), []string{}),
}
if c.Bool("interactive") {
if err := promptForConfig(&config); err != nil {
return err
}
}
// write config file
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write nip34.json: %w", err)
}
log("Created %s\n", color.GreenString(configPath))
// parse owner to npub
pk, err := parsePubKey(config.Owner)
if err != nil {
return fmt.Errorf("invalid owner public key: %w", err)
}
ownerNpub := nip19.EncodeNpub(pk)
// check existing git remotes
cmd = exec.Command("git", "remote", "-v")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get git remotes: %w", err)
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
var nostrRemote string
for _, remote := range remotes {
if strings.Contains(remote, "nostr://") {
parts := strings.Fields(remote)
if len(parts) >= 2 {
nostrRemote = parts[1]
break
}
}
}
if nostrRemote == "" {
// set the remote
remoteURL := fmt.Sprintf("nostr://%s/%s/%s", ownerNpub, config.GraspServers[0], config.Identifier)
cmd = exec.Command("git", "remote", "add", "origin", remoteURL)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to add git remote: %w", err)
}
log("Added git remote: %s\n", remoteURL)
} else {
// validate existing remote
if !strings.HasPrefix(nostrRemote, "nostr://") {
return fmt.Errorf("invalid nostr remote URL: %s", nostrRemote)
}
urlParts := strings.TrimPrefix(nostrRemote, "nostr://")
parts := strings.Split(urlParts, "/")
if len(parts) != 3 {
return fmt.Errorf("invalid nostr URL format, expected nostr://<npub>/<relay_hostname>/<identifier>, got: %s", nostrRemote)
}
repoNpub := parts[0]
relayHostname := parts[1]
identifier := parts[2]
if repoNpub != ownerNpub {
return fmt.Errorf("git remote npub '%s' does not match owner '%s'", repoNpub, ownerNpub)
}
if !slices.Contains(config.GraspServers, relayHostname) {
return fmt.Errorf("git remote relay '%s' not in grasp servers %v", relayHostname, config.GraspServers)
}
if identifier != config.Identifier {
return fmt.Errorf("git remote identifier '%s' does not match config '%s'", identifier, config.Identifier)
}
}
// gitignore it
excludePath := ".git/info/exclude"
excludeContent, err := os.ReadFile(excludePath)
if err != nil {
// file doesn't exist, create it
excludeContent = []byte("")
}
// check if nip34.json is already in exclude
if !strings.Contains(string(excludeContent), "nip34.json") {
newContent := string(excludeContent)
if len(newContent) > 0 && !strings.HasSuffix(newContent, "\n") {
newContent += "\n"
}
newContent += "nip34.json\n"
if err := os.WriteFile(excludePath, []byte(newContent), 0644); err != nil {
log(color.YellowString("failed to add nip34.json to .git/info/exclude: %v\n", err))
} else {
log("added nip34.json to %s\n", color.GreenString(".git/info/exclude"))
}
}
log("edit %s if needed, then run %s to publish.\n",
color.CyanString("nip34.json"),
color.CyanString("nak git announce"))
return nil
},
}
func repositoriesEqual(a, b nip34.Repository) bool {
if a.ID != b.ID || a.Name != b.Name || a.Description != b.Description {
return false
}
if a.EarliestUniqueCommitID != b.EarliestUniqueCommitID {
return false
}
if len(a.Web) != len(b.Web) || len(a.Clone) != len(b.Clone) ||
len(a.Relays) != len(b.Relays) || len(a.Maintainers) != len(b.Maintainers) {
return false
}
for i := range a.Web {
if a.Web[i] != b.Web[i] {
return false
}
}
for i := range a.Clone {
if a.Clone[i] != b.Clone[i] {
return false
}
}
for i := range a.Relays {
if a.Relays[i] != b.Relays[i] {
return false
}
}
for i := range a.Maintainers {
if a.Maintainers[i] != b.Maintainers[i] {
return false
}
}
return true
}
func promptForConfig(config *Nip34Config) error {
rlConfig := &readline.Config{
Stdout: os.Stderr,
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
}
rl, err := readline.NewEx(rlConfig)
if err != nil {
return err
}
defer rl.Close()
promptString := func(currentVal *string, prompt string) error {
rl.SetPrompt(color.YellowString("%s [%s]: ", prompt, *currentVal))
answer, err := rl.Readline()
if err != nil {
return err
}
answer = strings.TrimSpace(answer)
if answer != "" {
*currentVal = answer
}
return nil
}
promptSlice := func(currentVal *[]string, prompt string) error {
defaultStr := strings.Join(*currentVal, ", ")
rl.SetPrompt(color.YellowString("%s (comma-separated) [%s]: ", prompt, defaultStr))
answer, err := rl.Readline()
if err != nil {
return err
}
answer = strings.TrimSpace(answer)
if answer != "" {
parts := strings.Split(answer, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
result = append(result, trimmed)
}
}
*currentVal = result
}
return nil
}
log("\nenter repository details (press Enter to keep default):\n\n")
if err := promptString(&config.Identifier, "identifier"); err != nil {
return err
}
if err := promptString(&config.Name, "name"); err != nil {
return err
}
if err := promptString(&config.Description, "description"); err != nil {
return err
}
if err := promptString(&config.Owner, "owner (npub or hex)"); err != nil {
return err
}
if err := promptSlice(&config.GraspServers, "grasp servers"); err != nil {
return err
}
if err := promptSlice(&config.Web, "web URLs"); err != nil {
return err
}
if err := promptSlice(&config.Maintainers, "other maintainers"); err != nil {
return err
}
log("\n")
return nil
}
func gitSanityCheck(localConfig Nip34Config, nostrRemote string) (nostr.PubKey, error) {
urlParts := strings.TrimPrefix(nostrRemote, "nostr://")
parts := strings.Split(urlParts, "/")
if len(parts) != 3 {
return nostr.ZeroPK, fmt.Errorf("invalid nostr URL format, expected nostr://<npub>/<relay_hostname>/<identifier>, got: %s", nostrRemote)
}
remoteNpub := parts[0]
remoteHostname := parts[1]
remoteIdentifier := parts[2]
ownerPk, err := parsePubKey(localConfig.Owner)
if err != nil {
return nostr.ZeroPK, fmt.Errorf("invalid owner public key: %w", err)
}
if nip19.EncodeNpub(ownerPk) != remoteNpub {
return nostr.ZeroPK, fmt.Errorf("owner in nip34.json does not match git remote npub")
}
if remoteIdentifier != localConfig.Identifier {
return nostr.ZeroPK, fmt.Errorf("git remote identifier '%s' differs from nip34.json identifier '%s'", remoteIdentifier, localConfig.Identifier)
}
if !slices.Contains(localConfig.GraspServers, remoteHostname) {
return nostr.ZeroPK, fmt.Errorf("git remote relay '%s' not in grasp servers %v", remoteHostname, localConfig.GraspServers)
}
return ownerPk, nil
}
var gitPush = &cli.Command{
Name: "push",
Usage: "push git changes",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
// setup signer
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return fmt.Errorf("failed to gather keyer: %w", err)
}
// log publishing as npub
currentPk, _ := kr.GetPublicKey(ctx)
currentNpub := nip19.EncodeNpub(currentPk)
log("publishing as %s\n", color.CyanString(currentNpub))
// read nip34.json configuration
configPath := "nip34.json"
var localConfig Nip34Config
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
}
if err := json.Unmarshal(data, &localConfig); err != nil {
return fmt.Errorf("failed to parse nip34.json: %w", err)
}
// get git remotes
cmd := exec.Command("git", "remote", "-v")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get git remotes: %w", err)
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
var nostrRemote string
for _, remote := range remotes {
if strings.Contains(remote, "nostr://") {
parts := strings.Fields(remote)
if len(parts) >= 2 {
nostrRemote = parts[1]
break
}
}
}
if nostrRemote == "" {
return fmt.Errorf("no nostr:// remote found")
}
// parse the URL: nostr://<npub>/<relay_hostname>/<identifier>
if !strings.HasPrefix(nostrRemote, "nostr://") {
return fmt.Errorf("invalid nostr remote URL: %s", nostrRemote)
}
ownerPk, err := gitSanityCheck(localConfig, nostrRemote)
if err != nil {
return err
}
// fetch repository announcement (30617) and state (30618) events
var repo nip34.Repository
var state nip34.RepositoryState
relays := append(sys.FetchOutboxRelays(ctx, ownerPk, 3), localConfig.GraspServers...)
results := sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{30617, 30618},
Tags: nostr.TagMap{
"d": []string{localConfig.Identifier},
},
Limit: 2,
}, nostr.SubscriptionOptions{
Label: "nak-git-push",
})
for ie := range results {
if ie.Event.Kind == 30617 {
repo = nip34.ParseRepository(ie.Event)
} else if ie.Event.Kind == 30618 {
state = nip34.ParseRepositoryState(ie.Event)
}
}
if repo.Event.ID == nostr.ZeroID {
return fmt.Errorf("no existing repository announcement found")
}
// check if signer matches owner or is in maintainers
if currentPk != ownerPk && !slices.Contains(repo.Maintainers, currentPk) {
return fmt.Errorf("current user is not allowed to push")
}
if state.Event.ID != nostr.ZeroID {
log("found state event: %s\n", state.Event.ID)
}
// get current branch and commit
res, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
currentBranch := strings.TrimSpace(string(res))
res, err = exec.Command("git", "rev-parse", "HEAD").Output()
if err != nil {
return fmt.Errorf("failed to get current commit: %w", err)
}
currentCommit := strings.TrimSpace(string(res))
log("Current branch: %s, commit: %s\n", currentBranch, currentCommit)
// create a new state if we didn't find any
if state.Event.ID == nostr.ZeroID {
state = nip34.RepositoryState{
ID: repo.ID,
Branches: make(map[string]string),
Tags: make(map[string]string),
}
}
// update the branch
state.Branches[currentBranch] = currentCommit
log("> setting branch %s to commit %s\n", currentBranch, currentCommit)
// set the HEAD to the current branch if none is set
if state.HEAD == "" {
state.HEAD = currentBranch
log("> setting HEAD to branch %s\n", currentBranch)
}
// create and sign the new state event
newStateEvent := state.ToEvent()
err = kr.SignEvent(ctx, &newStateEvent)
if err != nil {
return fmt.Errorf("error signing state event: %w", err)
}
log("> publishing updated repository state %s\n", newStateEvent.ID)
for res := range sys.Pool.PublishMany(ctx, relays, newStateEvent) {
if res.Error != nil {
log("(!) error publishing event to relay %s: %v\n", res.RelayURL, res.Error)
} else {
log("> published to relay %s\n", res.RelayURL)
}
}
// push to git clone URLs
for _, cloneURL := range repo.Clone {
log("> pushing to: %s\n", cloneURL)
cmd := exec.Command("git", "push", cloneURL, fmt.Sprintf("refs/heads/%s:refs/heads/%s", currentBranch, currentBranch))
output, err := cmd.CombinedOutput()
if err != nil {
log("(!) failed to push to %s: %v\n%s\n", cloneURL, err, string(output))
} else {
log("> successfully pushed to %s\n", cloneURL)
}
}
return nil
},
}
var gitPull = &cli.Command{
Name: "pull",
Usage: "pull git changes",
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("git pull not implemented yet")
},
}
var gitFetch = &cli.Command{
Name: "fetch",
Usage: "fetch git data",
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("git fetch not implemented yet")
},
}
var gitAnnounce = &cli.Command{
Name: "announce",
Usage: "announce repository to Nostr",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
// check if current directory is a git repository
cmd := exec.Command("git", "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
return fmt.Errorf("current directory is not a git repository")
}
// read nip34.json configuration
configPath := "nip34.json"
var localConfig Nip34Config
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
}
if err := json.Unmarshal(data, &localConfig); err != nil {
return fmt.Errorf("failed to parse nip34.json: %w", err)
}
// get git remotes
cmd = exec.Command("git", "remote", "-v")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get git remotes: %w", err)
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
var nostrRemote string
for _, remote := range remotes {
if strings.Contains(remote, "nostr://") {
parts := strings.Fields(remote)
if len(parts) >= 2 {
nostrRemote = parts[1]
break
}
}
}
if nostrRemote == "" {
return fmt.Errorf("no nostr:// remote found")
}
ownerPk, err := gitSanityCheck(localConfig, nostrRemote)
if err != nil {
return err
}
// setup signer
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return fmt.Errorf("failed to gather keyer: %w", err)
}
currentPk, _ := kr.GetPublicKey(ctx)
// current signer must match owner otherwise we can't announce
if currentPk != ownerPk {
return fmt.Errorf("current user is not the owner of this repository, can't announce")
}
// convert local config to nip34.Repository
localRepo := nip34.Repository{
ID: localConfig.Identifier,
Name: localConfig.Name,
Description: localConfig.Description,
Web: localConfig.Web,
Relays: localConfig.GraspServers,
EarliestUniqueCommitID: localConfig.EarliestUniqueCommit,
Maintainers: []nostr.PubKey{},
}
for _, server := range localConfig.GraspServers {
url := fmt.Sprintf("http%s/%s/%s.git", nostr.NormalizeURL(server)[2:], nip19.EncodeNpub(ownerPk), localConfig.Identifier)
localRepo.Clone = append(localRepo.Clone, url)
}
for _, maintainer := range localConfig.Maintainers {
if pk, err := parsePubKey(maintainer); err == nil {
localRepo.Maintainers = append(localRepo.Maintainers, pk)
} else {
log(color.YellowString("invalid maintainer pubkey '%s': %v\n", maintainer, err))
}
}
// fetch repository announcement (30617) events
var repo nip34.Repository
relays := append(sys.FetchOutboxRelays(ctx, ownerPk, 3), localConfig.GraspServers...)
results := sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{30617},
Tags: nostr.TagMap{
"d": []string{localConfig.Identifier},
},
Limit: 1,
}, nostr.SubscriptionOptions{
Label: "nak-git-announce",
})
for ie := range results {
repo = nip34.ParseRepository(ie.Event)
}
// publish repository announcement if needed
var needsAnnouncement bool
if repo.Event.ID == nostr.ZeroID {
log("no existing repository announcement found, will create one\n")
needsAnnouncement = true
} else if !repositoriesEqual(repo, localRepo) {
log("Local repository config differs from published announcement, will update\n")
needsAnnouncement = true
}
if needsAnnouncement {
announcementEvent := localRepo.ToEvent()
if err := kr.SignEvent(ctx, &announcementEvent); err != nil {
return fmt.Errorf("failed to sign announcement event: %w", err)
}
log("> publishing repository announcement %s\n", announcementEvent.ID)
for res := range sys.Pool.PublishMany(ctx, relays, announcementEvent) {
if res.Error != nil {
log("(!) error publishing announcement to relay %s: %v\n", res.RelayURL, res.Error)
} else {
log("> published announcement to relay %s\n", res.RelayURL)
}
}
} else {
log("Repository announcement is up to date\n")
}
return nil
},
}

32
go.mod
View File

@@ -4,16 +4,16 @@ go 1.24.1
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20250829192328-aa321f6e7f10
fiatjaf.com/nostr v0.0.0-20251112024900-1c43f0d66643
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.5
github.com/btcsuite/btcd/btcec/v2 v2.3.6
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/hanwen/go-fuse/v2 v2.7.2
github.com/json-iterator/go v1.1.12
github.com/liamg/magic v0.0.1
github.com/mailru/easyjson v0.9.0
github.com/mailru/easyjson v0.9.1
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/mattn/go-isatty v0.0.20
@@ -21,7 +21,8 @@ require (
github.com/mdp/qrterminal/v3 v3.2.1
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/sync v0.17.0
golang.org/x/term v0.32.0
)
@@ -35,19 +36,15 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/badger/v4 v4.5.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/pie/v2 v2.7.0 // indirect
github.com/elnosh/gonuts v0.4.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v24.12.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
@@ -59,26 +56,23 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/tetratelabs/wazero v1.8.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.2 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

133
go.sum
View File

@@ -1,11 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
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-20250818235102-c8d5aa703fab h1:zMp+G9Et5Z7ku/WUflZpmQzDIAB/Ah00Ms3cMtX9Pw4=
fiatjaf.com/nostr v0.0.0-20250818235102-c8d5aa703fab/go.mod h1:j7AfnEAevFuLcpH4Y1RYM27sYJfshL3An6ZSAQNlUlY=
fiatjaf.com/nostr v0.0.0-20250829192328-aa321f6e7f10 h1:T1rQuoQgC6GwxJTLTDjli8ZauCtOa+BjybU3pxCN84w=
fiatjaf.com/nostr v0.0.0-20250829192328-aa321f6e7f10/go.mod h1:92kdTV0aFobQ14aq5IMpkYy3lGI8RUabMwN3Cfv/qmg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
fiatjaf.com/nostr v0.0.0-20251112024900-1c43f0d66643 h1:GcoAN1FQV+rayCIklvj+mIB/ZR3Oni98C3bS/M+vzts=
fiatjaf.com/nostr v0.0.0-20251112024900-1c43f0d66643/go.mod h1:Nq86Jjsd0OmsOEImUg0iCcLuqM5B67Nj2eu/2dP74Ss=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
@@ -24,8 +20,8 @@ github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU=
github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E=
github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
@@ -43,7 +39,6 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -54,10 +49,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -69,14 +62,10 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgraph-io/badger/v4 v4.5.0 h1:TeJE3I1pIWLBjYhIYCA1+uxrjWEoJXImFBMEBVSm16g=
github.com/dgraph-io/badger/v4 v4.5.0/go.mod h1:ysgYmIeG8dS/E8kwxT7xHyc7MkmwNYLRoYnFbr7387A=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I=
github.com/dgraph-io/ristretto/v2 v2.1.0/go.mod h1:uejeqfYXpUomfse0+lO+13ATz4TypQYLJZzBSAemuB4=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
@@ -84,10 +73,6 @@ github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3p
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
github.com/elnosh/gonuts v0.4.2 h1:/WubPAWGxTE+okJ0WPvmtEzTzpi04RGxiTHAF1FYU+M=
github.com/elnosh/gonuts v0.4.2/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
@@ -96,34 +81,20 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v24.12.23+incompatible h1:ubBKR94NR4pXUCY/MUsRVzd9umNW7ht7EG9hHfS9FX8=
github.com/google/flatbuffers v24.12.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -152,8 +123,8 @@ github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
@@ -186,7 +157,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
@@ -194,14 +164,9 @@ github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
@@ -209,8 +174,9 @@ github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmc
github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -230,42 +196,25 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
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=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250717185816-542afb5b7346 h1:vuCObX8mQzik1tfEcYxWZBuVsmQtD1IjxCyPKM18Bh4=
golang.org/x/exp v0.0.0-20250717185816-542afb5b7346/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -275,45 +224,25 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
@@ -326,7 +255,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -464,6 +464,59 @@ func askConfirmation(msg string) bool {
}
}
func parsePubKey(value string) (nostr.PubKey, error) {
pk, err := nostr.PubKeyFromHex(value)
if err == nil {
return pk, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "npub":
if pk, ok := decoded.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if profile, ok := decoded.(nostr.ProfilePointer); ok {
return profile.PublicKey, nil
}
}
}
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
}
func parseEventID(value string) (nostr.ID, error) {
id, err := nostr.IDFromHex(value)
if err == nil {
return id, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "note":
if id, ok := decoded.(nostr.ID); ok {
return id, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
}
}
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string) string {
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
}
return value
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string

View File

@@ -36,6 +36,7 @@ var app = &cli.Command{
key,
verify,
relay,
admin,
bunker,
serve,
blossomCmd,
@@ -47,6 +48,7 @@ var app = &cli.Command{
curl,
fsCmd,
publish,
git,
},
Version: version,
Flags: []cli.Flag{

View File

@@ -6,9 +6,8 @@ import (
"os"
"path/filepath"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/badgerh"
"fiatjaf.com/nostr/sdk/hints/bbolth"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -21,7 +20,7 @@ var (
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
configPath := c.String("config-path")
if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.bg")
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); err == nil {
@@ -31,7 +30,7 @@ func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
}
}
if hintsFileExists && hintsFilePath != "" {
hintsdb, err := badgerh.NewBadgerHints(hintsFilePath)
hintsdb, err := bbolth.NewBoltHints(hintsFilePath)
if err == nil {
sys.Hints = hintsdb
}
@@ -58,9 +57,9 @@ var outbox = &cli.Command{
}
os.MkdirAll(hintsFilePath, 0755)
_, err := badgerh.NewBadgerHints(hintsFilePath)
_, err := bbolth.NewBoltHints(hintsFilePath)
if err != nil {
return fmt.Errorf("failed to create badger hints db at '%s': %w", hintsFilePath, err)
return fmt.Errorf("failed to create bolt hints db at '%s': %w", hintsFilePath, err)
}
log("initialized hints database at %s\n", hintsFilePath)
@@ -82,7 +81,7 @@ var outbox = &cli.Command{
return fmt.Errorf("expected exactly one argument (pubkey)")
}
pk, err := nostr.PubKeyFromHex(c.Args().First())
pk, err := parsePubKey(c.Args().First())
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
}

177
relay.go
View File

@@ -1,30 +1,19 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip86"
"github.com/urfave/cli/v3"
)
var relay = &cli.Command{
Name: "relay",
Usage: "gets the relay information document for the given relay, as JSON -- or allows usage of the relay management API.",
Description: `examples:
fetching relay information:
Usage: "gets the relay information document for the given relay, as JSON",
Description: `
nak relay nostr.wine
managing a relay
nak relay nostr.wine banevent --sec 1234 --id 037eb3751073770ff17483b1b1ff125866cd5147668271975ef0a8a8e7ee184a --reason "I don't like it"`,
`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -44,164 +33,4 @@ var relay = &cli.Command{
}
return nil
},
Commands: (func() []*cli.Command {
methods := []struct {
method string
args []string
}{
{"allowpubkey", []string{"pubkey", "reason"}},
{"banpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"allowpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"listeventsneedingmoderation", nil},
{"allowevent", []string{"id", "reason"}},
{"banevent", []string{"id", "reason"}},
{"listbannedevents", nil},
{"changerelayname", []string{"name"}},
{"changerelaydescription", []string{"description"}},
{"changerelayicon", []string{"icon"}},
{"allowkind", []string{"kind"}},
{"disallowkind", []string{"kind"}},
{"listallowedkinds", nil},
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
}
commands := make([]*cli.Command, 0, len(methods))
for _, def := range methods {
def := def
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
for i, argName := range def.args {
flags[i] = declareFlag(argName)
}
flags = append(flags, defaultKeyFlags...)
cmd := &cli.Command{
Name: def.method,
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
Description: fmt.Sprintf(
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
Flags: flags,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
params := make([]any, len(def.args))
for i, argName := range def.args {
params[i] = getArgument(c, argName)
}
req := nip86.Request{Method: def.method, Params: params}
reqj, _ := json.Marshal(req)
relayUrls := c.Args().Slice()
if len(relayUrls) == 0 {
stdout(string(reqj))
return nil
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
for _, relayUrl := range relayUrls {
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
log("calling '%s' on %s... ", def.method, httpUrl)
body := bytes.NewBuffer(nil)
body.Write(reqj)
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Authorization
payloadHash := sha256.Sum256(reqj)
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return fmt.Errorf("failed to sign token event: %w", err)
}
evtj, _ := json.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
// Content-Type
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// make request to relay
resp, err := http.DefaultClient.Do(req)
if err != nil {
log("failed: %s\n", err)
continue
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log("failed to read response: %s\n", err)
continue
}
if resp.StatusCode >= 300 {
log("failed with status %d\n", resp.StatusCode)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
var response nip86.Response
if err := json.Unmarshal(b, &response); err != nil {
log("bad json response: %s\n", err)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
pretty, _ := json.MarshalIndent(response, "", " ")
stdout(string(pretty))
}
return nil
},
}
commands = append(commands, cmd)
}
return commands
})(),
}
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
return c.Int(argName)
default:
return c.String(argName)
}
}

217
req.go
View File

@@ -1,17 +1,25 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"os"
"slices"
"strings"
"sync"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
)
const (
@@ -35,6 +43,11 @@ example:
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
@@ -44,6 +57,17 @@ example:
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "use outbox relays from specified public keys",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
@@ -76,8 +100,23 @@ example:
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
if negentropy {
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
return fmt.Errorf("negentropy is incompatible with --stream, --outbox or --paginate")
}
}
if c.Bool("paginate") && c.Bool("stream") {
return fmt.Errorf("incompatible flags --paginate and --stream")
}
if c.Bool("paginate") && c.Bool("outbox") {
return fmt.Errorf("incompatible flags --paginate and --outbox")
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
@@ -129,34 +168,145 @@ example:
return err
}
if len(relayUrls) > 0 {
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
for _, url := range relayUrls {
ch, err := nip77.FetchIDsOnly(ctx, url, filter)
if len(relayUrls) > 0 || c.Bool("outbox") {
if negentropy {
store := &slicestore.SliceStore{}
store.Init()
if syncFile := c.String("only-missing"); syncFile != "" {
file, err := os.Open(syncFile)
if err != nil {
log("negentropy call to %s failed: %s", url, err)
continue
return fmt.Errorf("failed to open sync file: %w", err)
}
for id := range ch {
if _, ok := seen[id]; ok {
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
for scanner.Scan() {
var evt nostr.Event
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
continue
}
seen[id] = struct{}{}
stdout(id)
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
continue
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read sync file: %w", err)
}
}
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
}
var source nostr.Querier = nil
if c.IsSet("only-missing") {
source = target
}
handle := nip77.SyncEventsFromIDs
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
handle = func(ctx context.Context, dir nip77.Direction) {
for id := range dir.Items {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id.Hex())
}
}
}
for _, url := range relayUrls {
err := nip77.NegentropySync(ctx, url, filter, source, target, handle)
if err != nil {
log("negentropy sync from %s failed: %s", url, err)
}
}
} else {
fn := sys.Pool.FetchMany
if c.Bool("paginate") {
fn = sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
} else if c.Bool("stream") {
fn = sys.Pool.SubscribeMany
var results chan nostr.RelayEvent
opts := nostr.SubscriptionOptions{
Label: "nak-req",
}
for ie := range fn(ctx, relayUrls, filter, 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)
}
}
@@ -183,13 +333,13 @@ var reqFilterFlags = []cli.Flag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&IDSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
@@ -261,19 +411,19 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
tags = append(tags, []string{"d", decodeTagValue(dtag)})
}
if len(tags) > 0 && filter.Tags == nil {
@@ -302,3 +452,18 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
return nil
}
type PrintingQuerierPublisher struct {
nostr.QuerierPublisher
}
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
stdout(evt)
return nil
} else if err == eventstore.ErrDupEvent {
return nil
} else {
return err
}
}