naco-api/internal/nostr/client.go
Nacorid 4f107da9ee
fix: harden Nostr client relay handling and error propagation
- budget relay queries across a shared 20s deadline (5s per relay) and
  dedupe events by ID across relays
- surface 'could not connect to any relay' when no query succeeded and
  abort early on relay-connect and stream-cancel errors
- guard zero/negative limits across all fetchers
- GetActor falls through relays and rejects empty profile metadata;
  SearchActors propagates relay errors instead of silently returning none
- NewNostrClient validates relay ws://wss:// schemes, trims, and dedupes
2026-09-09 23:17:56 +00:00

372 lines
9.6 KiB
Go

package nostr
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/nacorid/naco-api/internal/actor"
)
const (
// relayQueryTimeout bounds connect + subscribe + collect against a single
// relay. The websocket dial, subscription handshake and event collection all
// share this budget.
relayQueryTimeout = 5 * time.Second
// maxTotalRelayTime caps the fan-out across all relays when the caller's
// context carries no deadline of its own. Without it a long relay list could
// multiply the per-relay budget unboundedly.
maxTotalRelayTime = 20 * time.Second
)
type Client struct {
relays []string
}
func NewNostrClient(relays []string) (*Client, error) {
seen := make(map[string]struct{}, len(relays))
cleaned := make([]string, 0, len(relays))
for _, r := range relays {
r = strings.TrimSpace(r)
if r == "" {
continue
}
u, err := url.Parse(r)
if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") {
return nil, fmt.Errorf("invalid relay URL %q: must be a ws:// or wss:// endpoint", r)
}
if _, dup := seen[r]; dup {
continue
}
seen[r] = struct{}{}
cleaned = append(cleaned, r)
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("at least one relay is required")
}
return &Client{relays: cleaned}, nil
}
type Post struct {
ID string
Author string
Content string
CreatedAt time.Time
}
// queryBudget returns a context bounded to maxTotalRelayTime when ctx has no
// deadline, so the relay fan-out cannot run away across many relays. Callers
// must invoke the returned cancel.
func (c *Client) queryBudget(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, maxTotalRelayTime)
}
// collectFromRelay connects to url within ctx, subscribes with filter and
// appends matching events to posts (deduplicated by event id) until the limit is
// reached, EOSE arrives, or ctx is done. It reports whether the relay was
// actually queryable (connected + subscribed), letting callers distinguish a
// total relay outage from a legitimate empty result.
func (c *Client) collectFromRelay(ctx context.Context, url string, filter nostr.Filter, limit int, posts *[]*Post, seen map[string]struct{}) (used bool) {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
return false
}
defer relay.Close()
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
if err != nil {
return false
}
defer sub.Unsub()
for {
select {
case <-sub.EndOfStoredEvents:
return true
case ev, ok := <-sub.Events:
if !ok {
return true
}
id := ev.ID.Hex()
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
*posts = append(*posts, &Post{
ID: id,
Author: ev.PubKey.Hex(),
Content: ev.Content,
CreatedAt: ev.CreatedAt.Time(),
})
if len(*posts) >= limit {
return true
}
case <-ctx.Done():
return true
}
}
}
// collectPosts subscribes to filter across every relay, stopping once limit posts
// are collected or the query context is done. It returns the deduplicated posts,
// or an error if no relay could be queried at all or the operation was aborted.
func (c *Client) collectPosts(ctx context.Context, filter nostr.Filter, limit int) ([]*Post, error) {
posts := make([]*Post, 0, limit)
seen := make(map[string]struct{})
queryCtx, cancel := c.queryBudget(ctx)
defer cancel()
var anyUsed bool
for _, url := range c.relays {
if queryCtx.Err() != nil {
break
}
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
used := c.collectFromRelay(relayCtx, url, filter, limit, &posts, seen)
relayCancel()
anyUsed = anyUsed || used
if len(posts) >= limit {
break
}
}
if err := queryCtx.Err(); err != nil {
return nil, fmt.Errorf("nostr query aborted: %w", err)
}
if !anyUsed {
return nil, fmt.Errorf("could not connect to any relay")
}
return posts, nil
}
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
eid, err := resolveEventID(id)
if err != nil {
return nil, err
}
filter := nostr.Filter{
IDs: []nostr.ID{eid},
Kinds: []nostr.Kind{1},
}
posts, err := c.collectPosts(ctx, filter, 1)
if err != nil {
return nil, err
}
if len(posts) == 0 {
return nil, fmt.Errorf("post %q not found across configured relays", id)
}
return posts[0], nil
}
func (c *Client) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Limit: limit,
}
return c.collectPosts(ctx, filter, limit)
}
func (c *Client) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Since: nostr.Timestamp(start.Unix()),
Until: nostr.Timestamp(end.Unix()),
Limit: limit,
}
return c.collectPosts(ctx, filter, limit)
}
func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
npub := nip19.EncodeNpub(pubkey)
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{0},
Limit: 1,
}
queryCtx, cancel := c.queryBudget(ctx)
defer cancel()
var anyUsed bool
for _, url := range c.relays {
if queryCtx.Err() != nil {
break
}
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
profile, used := c.actorFromRelay(relayCtx, url, filter, npub)
relayCancel()
anyUsed = anyUsed || used
if profile != nil {
return profile, nil
}
}
if err := queryCtx.Err(); err != nil {
return nil, fmt.Errorf("nostr query aborted: %w", err)
}
if !anyUsed {
return nil, fmt.Errorf("could not connect to any relay")
}
return nil, fmt.Errorf("no profile metadata found for %q", author)
}
// actorFromRelay looks up one kind-0 metadata event on url. It returns a
// non-nil profile when a usable metadata event was found. used reports whether
// the relay was queried at all (so a fully unreachable relay list is
// distinguishable from an author without metadata).
func (c *Client) actorFromRelay(ctx context.Context, url string, filter nostr.Filter, npub string) (profile *actor.Profile, used bool) {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
return nil, false
}
defer relay.Close()
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
if err != nil {
return nil, false
}
defer sub.Unsub()
select {
case <-sub.EndOfStoredEvents:
return nil, true
case ev, ok := <-sub.Events:
if !ok {
return nil, true
}
return profileFromEvent(ev, npub), true
case <-ctx.Done():
return nil, true
}
}
// profileFromEvent builds an actor.Profile from a kind-0 metadata event, or nil
// when the content is not valid JSON or carries no usable fields.
func profileFromEvent(ev nostr.Event, npub string) *actor.Profile {
var meta struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
About string `json:"about"`
Picture string `json:"picture"`
Website string `json:"website"`
}
if err := json.Unmarshal([]byte(ev.Content), &meta); err != nil {
return nil
}
if meta.Name == "" && meta.DisplayName == "" && meta.About == "" && meta.Picture == "" {
return nil
}
displayName := meta.DisplayName
if displayName == "" {
displayName = meta.Name
}
return &actor.Profile{
Platform: "Nostr",
Handle: npub,
DisplayName: displayName,
AvatarURL: meta.Picture,
Bio: meta.About,
URL: "https://njump.me/" + npub,
}
}
func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
if _, err := resolvePubkey(query); err != nil {
// Not a pubkey we can resolve (e.g. a name query). Treat it as no match
// rather than an error so a cross-platform search isn't failed by a
// non-nostr query.
return nil, nil
}
prof, err := c.GetActor(ctx, query)
if err != nil {
return nil, err
}
return []*actor.Profile{prof}, nil
}
func resolvePubkey(actor string) (nostr.PubKey, error) {
prefix, value, err := nip19.Decode(actor)
if err == nil {
switch prefix {
case "npub":
if pk, ok := value.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if pp, ok := value.(nostr.ProfilePointer); ok {
return pp.PublicKey, nil
}
}
return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
if len(actor) == 64 {
pk, e := nostr.PubKeyFromHex(actor)
if e != nil {
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e)
}
return pk, nil
}
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err)
}
func resolveEventID(id string) (nostr.ID, error) {
prefix, value, err := nip19.Decode(id)
if err == nil {
switch prefix {
case "note":
if eid, ok := value.(nostr.ID); ok {
return eid, nil
}
case "nevent":
if ep, ok := value.(nostr.EventPointer); ok {
return ep.ID, nil
}
}
return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
if len(id) == 64 {
eid, e := nostr.IDFromHex(id)
if e != nil {
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e)
}
return eid, nil
}
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err)
}