Compare commits

...

9 Commits

Author SHA1 Message Date
fiatjaf
037e8efcc6 count: support reading filters from stdin like req. 2026-02-12 14:34:24 -03:00
fiatjaf
1b380dea9a count: support the same flags as req.
fixes https://github.com/fiatjaf/nak/issues/103
2026-02-12 14:25:57 -03:00
fiatjaf
f126b3f7ee mcp: searching for pubkeys and nip05s returns them directly. 2026-02-06 13:52:15 -03:00
fiatjaf
dc5ffe5129 bunker: fix authorizing pubkeys from --authorized-secrets.
closes https://github.com/fiatjaf/nak/issues/102
2026-02-04 23:26:26 -03:00
Yasuhiro Matsumoto
7637b5018f avoid panic when mint URL has no '://' 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
d5ab34bb2f remove redundant if err != nil in main 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
49345333c4 check dateparser error before using date.Time 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
b5de7b78bc prevent panic on AUTH challenge tag (nil or len<2) 2026-02-01 14:24:51 -03:00
fiatjaf
ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
9 changed files with 127 additions and 180 deletions

View File

@@ -6,14 +6,14 @@ install with this one-liner:
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
``` ```
- or install with `go install github.com/fiatjaf/nak@latest` if you have [Go](https://pkg.go.dev) set up. - or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually. - or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then - or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`; - install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`. - or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**. - or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**. - or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install ripgrep` if you use **Nix**. - or install with `nix-env --install nak` if you use **Nix**.
## what can you do with it? ## what can you do with it?

View File

@@ -418,6 +418,13 @@ var bunker = &cli.Command{
return true return true
} }
if slices.Contains(authorizedSecrets, secret) { if slices.Contains(authorizedSecrets, secret) {
// add client to authorized list for subsequent requests
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
if persist != nil {
persist()
}
}
return true return true
} }

183
count.go
View File

@@ -9,63 +9,17 @@ import (
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45" "fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog" "fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
var count = &cli.Command{ var count = &cli.Command{
Name: "count", Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays", Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`, Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true, DisableSliceFlagSeparator: true,
Flags: []cli.Flag{ Flags: reqFilterFlags,
&PubKeySliceFlag{ ArgsUsage: "[relay...]",
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
},
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
biggerUrlSize := 0 biggerUrlSize := 0
relayUrls := c.Args().Slice() relayUrls := c.Args().Slice()
@@ -84,95 +38,62 @@ var count = &cli.Command{
} }
} }
filter := nostr.Filter{} // go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 { filter := nostr.Filter{}
filter.Authors = authors if stdinFilter != "" {
} if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 { ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
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", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
}
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
continue continue
} }
}
var hasHLLStr string if err := applyFlagsToFilter(c, &filter); err != nil {
if hll != nil && len(hllRegisters) == 256 { return err
hll.MergeRegisters(hllRegisters) }
hasHLLStr = " 📋"
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
} }
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr) if err != nil {
successes++ fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " (hll)"
}
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
} }
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
} }
exitIfLineProcessingError(ctx)
return nil return nil
}, },
} }

View File

@@ -105,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
DefaultTimezone: time.Local, DefaultTimezone: time.Local,
CurrentTime: time.Now(), CurrentTime: time.Now(),
}, value) }, value)
ts = date.Time
if err != nil { if err != nil {
return err return err
} }
ts = date.Time
} }
if t.timestamp != nil { if t.timestamp != nil {

View File

@@ -261,7 +261,7 @@ func connectToSingleRelay(
for range 5 { for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error { if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge") challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" { if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack return fmt.Errorf("auth not received yet *****") // what a giant hack
} }
return preAuthSigner(ctx, c, logthis, authEvent) return preAuthSigner(ctx, c, logthis, authEvent)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -e set -e
# Detect OS # detect OS
detect_os() { detect_os() {
case "$(uname -s)" in case "$(uname -s)" in
Linux*) echo "linux";; Linux*) echo "linux";;
@@ -9,65 +9,65 @@ detect_os() {
FreeBSD*) echo "freebsd";; FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";; MINGW*|MSYS*|CYGWIN*) echo "windows";;
*) *)
echo "Error: Unsupported OS $(uname -s)" >&2 echo "error: unsupported OS $(uname -s)" >&2
exit 1 exit 1
;; ;;
esac esac
} }
# Detect architecture # detect architecture
detect_arch() { detect_arch() {
case "$(uname -m)" in case "$(uname -m)" in
x86_64|amd64) echo "amd64";; x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";; aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";; riscv64) echo "riscv64";;
*) *)
echo "Error: Unsupported architecture $(uname -m)" >&2 echo "error: unsupported architecture $(uname -m)" >&2
exit 1 exit 1
;; ;;
esac esac
} }
# Set install directory # set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Detect platform # detect platform
OS=$(detect_os) OS=$(detect_os)
ARCH=$(detect_arch) ARCH=$(detect_arch)
echo "Installing nak ($OS-$ARCH) to $INSTALL_DIR..." echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# Check if curl is available # check if curl is available
command -v curl >/dev/null 2>&1 || { echo "Error: curl is required" >&2; exit 1; } command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# Get latest release tag # get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest) RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}" TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}" TAG="${TAG#*\"}"
TAG="${TAG%%\"*}" TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "Error: Failed to fetch release info" >&2; exit 1; } [ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# Construct download URL # construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}" BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe" [ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}" DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# Create install directory and download # create install directory and download
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak" TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe" [ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH" chmod +x "$TARGET_PATH"
echo "Installed nak $TAG to $TARGET_PATH" echo "installed nak $TAG to $TARGET_PATH"
# Check if install dir is in PATH # check if install dir is in PATH
case ":$PATH:" in case ":$PATH:" in
*":$INSTALL_DIR:"*) ;; *":$INSTALL_DIR:"*) ;;
*) echo "Note: Add $INSTALL_DIR to your PATH" ;; *) echo "note: add $INSTALL_DIR to your PATH" ;;
esac esac
else else
echo "Error: Download failed from $DOWNLOAD_URL" >&2 echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1 exit 1
fi fi

View File

@@ -127,9 +127,7 @@ func main() {
// a megahack to enable this curl command proxy // a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" { if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil { if err := realCurl(); err != nil {
if err != nil { log(color.YellowString(err.Error()) + "\n")
log(color.YellowString(err.Error()) + "\n")
}
colors.reset() colors.reset()
os.Exit(1) os.Exit(1)
} }
@@ -137,9 +135,7 @@ func main() {
} }
if err := app.Run(context.Background(), os.Args); err != nil { if err := app.Run(context.Background(), os.Args); err != nil {
if err != nil { log("%s\n", color.RedString(err.Error()))
log("%s\n", color.RedString(err.Error()))
}
colors.reset() colors.reset()
os.Exit(1) os.Exit(1)
} }

47
mcp.go
View File

@@ -158,29 +158,40 @@ var mcpServer = &cli.Command{
name := required[string](r, "name") name := required[string](r, "name")
limit, _ := optional[float64](r, "limit") limit, _ := optional[float64](r, "limit")
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
res := strings.Builder{} res := strings.Builder{}
res.Grow(500)
res.WriteString("search results: ") res.WriteString("search results: ")
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) { // check if input is already a valid pubkey
break if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
pm := sys.FetchProfileMetadata(ctx, pubkey)
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
pm.ShortName(), pm.PubKey.Hex(), pm.About))
} else {
// otherwise try to search
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
} }
} }
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
return mcp.NewToolResultText(res.String()), nil return mcp.NewToolResultText(res.String()), nil
}) })

View File

@@ -139,7 +139,11 @@ var wallet = &cli.Command{
} }
for _, url := range w.Mints { for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1]) if _, host, ok := strings.Cut(url, "://"); ok {
stdout(host)
} else {
stdout(url)
}
} }
closew() closew()
@@ -195,7 +199,11 @@ var wallet = &cli.Command{
} }
for _, token := range w.Tokens { for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1]) _, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
stdout(token.ID(), token.Proofs.Amount(), mintHost)
} }
closew() closew()
@@ -221,7 +229,11 @@ var wallet = &cli.Command{
for _, token := range w.Tokens { for _, token := range w.Tokens {
if slices.Contains(ids, token.ID()) { if slices.Contains(ids, token.ID()) {
w.DropToken(ctx, token.ID()) w.DropToken(ctx, token.ID())
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1]) _, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
} }
} }