tinyrelayfile
tinyrelay/internal/replication/websocket.go/

internal/replication/websocket.go raw
1package replication
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net"
9 "net/http"
10 "net/url"
11 "strings"
12 "sync"
13 "sync/atomic"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/event"
17 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
18 "github.com/coder/websocket"
19)
20
21// Socket is the message-oriented connection used by NostrTransport. The
22// caller that receives a Socket owns it and closes it when the exchange ends.
23type Socket interface {
24 Read(ctx context.Context) ([]byte, error)
25 Write(ctx context.Context, message []byte) error
26 Close(cause error) error
27}
28
29// Dialer opens a Socket for one relay target and returns its HTTP handshake.
30type Dialer interface {
31 Dial(ctx context.Context, target string) (Socket, *http.Response, error)
32}
33
34// IPResolver resolves relay hostnames for address-policy checks and dialing.
35type IPResolver interface {
36 LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
37}
38
39// WebsocketDialer validates websocket targets, blocks private addresses unless
40// AllowPrivate is set and optionally answers NIP-42 authentication challenges.
41// HTTPClient and Resolver are caller-supplied dependencies and remain owned by
42// the caller.
43type WebsocketDialer struct {
44 AllowPrivate bool
45 MaxMessageBytes int64
46 Resolver IPResolver
47 HTTPClient *http.Client
48 // Authenticate answers a NIP-42 challenge when configured for an
49 // operator-trusted private peer. Public relays remain unauthenticated.
50 Authenticate func(context.Context, string, string) (event.Event, error)
51}
52
53func (d WebsocketDialer) Dial(ctx context.Context, target string) (Socket, *http.Response, error) {
54 parsed, err := url.Parse(target)
55 if err != nil || (parsed.Scheme != "ws" && parsed.Scheme != "wss") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
56 return nil, nil, errors.New("replication: invalid websocket URL")
57 }
58 if d.MaxMessageBytes < 0 {
59 d.MaxMessageBytes = 8 << 20
60 }
61 resolver := d.Resolver
62 if resolver == nil {
63 resolver = net.DefaultResolver
64 }
65 if err := validateHost(ctx, parsed.Hostname(), resolver, d.AllowPrivate); err != nil {
66 return nil, nil, err
67 }
68 client := d.client(resolver, d.AllowPrivate)
69 conn, response, err := websocket.Dial(ctx, target, &websocket.DialOptions{HTTPClient: client})
70 if err != nil {
71 return nil, response, err
72 }
73 limit := d.MaxMessageBytes
74 if limit == 0 {
75 limit = -1
76 }
77 conn.SetReadLimit(limit)
78 if d.Authenticate != nil {
79 if err := authenticateSocket(ctx, conn, target, d.Authenticate); err != nil {
80 _ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
81 return nil, response, err
82 }
83 }
84 return coderSocket{conn: conn}, response, nil
85}
86
87func authenticateSocket(ctx context.Context, conn *websocket.Conn, target string, sign func(context.Context, string, string) (event.Event, error)) error {
88 readCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
89 defer cancel()
90 _, data, err := conn.Read(readCtx)
91 if err != nil {
92 return fmt.Errorf("replication: NIP-42 challenge: %w", err)
93 }
94 var raw []json.RawMessage
95 if err := json.Unmarshal(data, &raw); err != nil || len(raw) < 2 {
96 return errors.New("replication: invalid NIP-42 challenge")
97 }
98 var kind, challenge string
99 if err := json.Unmarshal(raw[0], &kind); err != nil || kind != "AUTH" || json.Unmarshal(raw[1], &challenge) != nil || challenge == "" {
100 return errors.New("replication: peer did not issue NIP-42 challenge")
101 }
102 proof, err := sign(ctx, target, challenge)
103 if err != nil {
104 return err
105 }
106 message, err := json.Marshal([]any{"AUTH", proof})
107 if err != nil {
108 return err
109 }
110 return conn.Write(ctx, websocket.MessageText, message)
111}
112
113func (d WebsocketDialer) client(resolver IPResolver, allowPrivate bool) *http.Client {
114 base := d.HTTPClient
115 if base == nil {
116 base = &http.Client{}
117 }
118 if base.Transport != nil {
119 if _, standard := base.Transport.(*http.Transport); !standard {
120 // Nonstandard transports are an explicit test/integration injection;
121 // the caller owns their proxy and address policy.
122 return base
123 }
124 }
125 transport := http.DefaultTransport.(*http.Transport).Clone()
126 if base.Transport != nil {
127 if source, ok := base.Transport.(*http.Transport); ok {
128 transport = source.Clone()
129 }
130 }
131 transport.Proxy = nil
132 transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
133 host, port, err := net.SplitHostPort(address)
134 if err != nil {
135 return nil, err
136 }
137 if err := validateHost(ctx, host, resolver, allowPrivate); err != nil {
138 return nil, err
139 }
140 ips, err := resolver.LookupIPAddr(ctx, host)
141 if err != nil || len(ips) == 0 {
142 if err == nil {
143 err = errors.New("no address")
144 }
145 return nil, fmt.Errorf("replication: resolve %s: %w", host, err)
146 }
147 var last error
148 for _, candidate := range ips {
149 if err := validateIP(candidate.IP, allowPrivate); err != nil {
150 return nil, err
151 }
152 conn, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port))
153 if dialErr == nil {
154 return conn, nil
155 }
156 last = dialErr
157 }
158 return nil, last
159 }
160 client := *base
161 client.Transport = transport
162 oldRedirect := base.CheckRedirect
163 client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
164 if err := validateURL(req.URL); err != nil {
165 return err
166 }
167 if err := validateHost(req.Context(), req.URL.Hostname(), resolver, allowPrivate); err != nil {
168 return err
169 }
170 if len(via) >= 10 {
171 return errors.New("replication: stopped after 10 redirects")
172 }
173 if oldRedirect != nil {
174 return oldRedirect(req, via)
175 }
176 return nil
177 }
178 return &client
179}
180
181func validateURL(target *url.URL) error {
182 if target == nil || (target.Scheme != "ws" && target.Scheme != "wss" && target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil || target.Fragment != "" {
183 return errors.New("replication: invalid websocket redirect")
184 }
185 return nil
186}
187
188func validateHost(ctx context.Context, host string, resolver IPResolver, allowPrivate bool) error {
189 if host == "" {
190 return errors.New("replication: websocket host is empty")
191 }
192 if ip := net.ParseIP(host); ip != nil {
193 return validateIP(ip, allowPrivate)
194 }
195 ips, err := resolver.LookupIPAddr(ctx, host)
196 if err != nil {
197 return fmt.Errorf("replication: resolve %s: %w", host, err)
198 }
199 if len(ips) == 0 {
200 return fmt.Errorf("replication: resolve %s: no address", host)
201 }
202 for _, item := range ips {
203 if err := validateIP(item.IP, allowPrivate); err != nil {
204 return err
205 }
206 }
207 return nil
208}
209
210func validateIP(ip net.IP, allowPrivate bool) error {
211 if allowPrivate {
212 return nil
213 }
214 if unusableIP(ip) {
215 return fmt.Errorf("replication: private or local relay address %s is blocked", ip)
216 }
217 return nil
218}
219
220func unusableIP(ip net.IP) bool {
221 if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() || ip.Equal(net.ParseIP("100.100.100.100")) {
222 return true
223 }
224 v4 := ip.To4()
225 if v4 == nil {
226 return false
227 }
228 // CGNAT/Tailscale and documentation/reserved IPv4 ranges are not public
229 // relay destinations unless the operator explicitly permits private peers.
230 if v4[0] == 100 && v4[1]&0xc0 == 64 {
231 return true
232 }
233 if v4[0] == 192 && v4[1] == 0 && v4[2] == 0 {
234 return true
235 }
236 if v4[0] == 198 && v4[1] >= 18 && v4[1] <= 19 {
237 return true
238 }
239 return v4[0] >= 240
240}
241
242type coderSocket struct{ conn *websocket.Conn }
243
244func (s coderSocket) Read(ctx context.Context) ([]byte, error) {
245 _, data, err := s.conn.Read(ctx)
246 return data, err
247}
248
249func (s coderSocket) Write(ctx context.Context, data []byte) error {
250 return s.conn.Write(ctx, websocket.MessageText, data)
251}
252
253func (s coderSocket) Close(err error) error {
254 code := websocket.StatusNormalClosure
255 if err != nil {
256 code = websocket.StatusInternalError
257 }
258 return s.conn.Close(code, "replication complete")
259}
260
261// NostrTransport implements PullTransport and PushTransport over Nostr
262// websocket protocols. Dialer defaults to WebsocketDialer when unset, and
263// Timeout defaults to 20 seconds.
264type NostrTransport struct {
265 Dialer Dialer
266 Timeout time.Duration
267 sequence atomic.Uint64
268 legacyMu sync.Mutex
269 legacy map[string]struct{}
270 // LegacyCache may be shared by short-lived transports created for the same
271 // tenant. Entries expire so a relay can gain NIP-77 support without a
272 // process restart.
273 LegacyCache *LegacyCache
274}
275
276const legacyProbeTTL = 4 * time.Hour
277
278type LegacyCache struct {
279 mu sync.Mutex
280 entries map[string]time.Time
281}
282
283func (c *LegacyCache) known(target string) bool {
284 c.mu.Lock()
285 defer c.mu.Unlock()
286 when, ok := c.entries[target]
287 if !ok || time.Since(when) >= legacyProbeTTL {
288 if ok {
289 delete(c.entries, target)
290 }
291 return false
292 }
293 return true
294}
295
296func (c *LegacyCache) remember(target string) {
297 c.mu.Lock()
298 defer c.mu.Unlock()
299 if c.entries == nil {
300 c.entries = make(map[string]time.Time)
301 }
302 if len(c.entries) >= 64 {
303 var oldest string
304 var oldestAt time.Time
305 for key, at := range c.entries {
306 if oldest == "" || at.Before(oldestAt) {
307 oldest, oldestAt = key, at
308 }
309 }
310 delete(c.entries, oldest)
311 }
312 c.entries[target] = time.Now()
313}
314
315// Subscribe keeps a bounded NIP-01 subscription open until ctx is canceled.
316// Reconnection belongs to the lifecycle owner so it can apply peer backoff
317// and avoid multiplying sockets when a relay is unhealthy.
318func (t *NostrTransport) Subscribe(ctx context.Context, target string, filter event.Filter, onEvent func(event.Event) error) error {
319 return t.SubscribeFilters(ctx, target, []event.Filter{filter}, onEvent)
320}
321
322// SubscribeFilters opens one subscription for a set of NIP-01 filters. Relays
323// treat filters in one REQ as an OR, which lets callers follow related event
324// families without opening a socket for each family.
325func (t *NostrTransport) SubscribeFilters(ctx context.Context, target string, filters []event.Filter, onEvent func(event.Event) error) error {
326 if len(filters) == 0 {
327 return errors.New("replication: live subscription has no filters")
328 }
329 socket, _, err := t.dial(ctx, target)
330 if err != nil {
331 return err
332 }
333 defer socket.Close(nil)
334 sub := fmt.Sprintf("tiny-live-%d", t.sequence.Add(1))
335 prepared := make([]event.Filter, len(filters))
336 for i, filter := range filters {
337 filter.Tags = ensureTags(filter.Tags)
338 prepared[i] = filter
339 }
340 message, err := json.Marshal(append([]any{"REQ", sub}, filtersToAny(prepared)...))
341 if err != nil {
342 return fmt.Errorf("encode subscription: %w", err)
343 }
344 if err := socket.Write(ctx, message); err != nil {
345 return err
346 }
347 for {
348 data, err := socket.Read(ctx)
349 if err != nil {
350 return err
351 }
352 var raw []json.RawMessage
353 if json.Unmarshal(data, &raw) != nil || len(raw) < 2 {
354 continue
355 }
356 var kind, id string
357 _ = json.Unmarshal(raw[0], &kind)
358 _ = json.Unmarshal(raw[1], &id)
359 if id != sub {
360 continue
361 }
362 if kind == "CLOSED" {
363 return errors.New("replication: live subscription closed")
364 }
365 if kind != "EVENT" || len(raw) < 3 {
366 continue
367 }
368 var item event.Event
369 if err := json.Unmarshal(raw[2], &item); err != nil {
370 continue
371 }
372 if err := event.Validate(item); err != nil || !filterMatchesAny(prepared, item) {
373 continue
374 }
375 if onEvent != nil {
376 if err := onEvent(item); err != nil {
377 return err
378 }
379 }
380 }
381}
382
383func filtersToAny(filters []event.Filter) []any {
384 out := make([]any, len(filters))
385 for i := range filters {
386 out[i] = filters[i]
387 }
388 return out
389}
390
391func filterMatchesAny(filters []event.Filter, item event.Event) bool {
392 for _, filter := range filters {
393 if event.Matches(filter, item) {
394 return true
395 }
396 }
397 return false
398}
399
400const (
401 // pullPageSize keeps a compatibility REQ small enough for relays to serve
402 // without allocating an unbounded result set.
403 pullPageSize = 500
404 // pullPageLimit prevents a corrupt or very large repository announcement
405 // from turning one maintenance pass into an unbounded import.
406 pullPageLimit = 20
407 pullEventLimit = pullPageSize * pullPageLimit
408)
409
410// ErrPullIncomplete indicates that a relay returned a full bounded history
411// window but the next page cannot be represented safely by an `until` cursor.
412// Callers should retain the result and retry with a larger or more capable
413// synchronization method.
414var ErrPullIncomplete = errors.New("replication: pull history is incomplete")
415
416// ErrNegentropyUnsupported means the peer does not implement NIP-77. It is
417// deliberately distinct from authentication, protocol and transport errors:
418// only this condition is safe to downgrade to an ordinary REQ pull.
419var ErrNegentropyUnsupported = errors.New("replication: negentropy unsupported")
420
421// QuerySynchronized prefers NIP-77 negentropy and falls back to bounded,
422// descending REQ pages when the peer does not implement it. GRASP peers are
423// commonly mixed-version, so lack of NIP-77 support must not prevent the
424// ordinary NIP-01 pull from completing.
425func (t *NostrTransport) QuerySynchronized(ctx context.Context, target string, filter event.Filter, local []syncprotocol.Item) ([]event.Event, error) {
426 if t.knownLegacy(target) {
427 return t.QueryPaginated(ctx, target, filter)
428 }
429 items, err := t.QueryNegentropy(ctx, target, filter, local)
430 if err == nil {
431 return items, nil
432 }
433 if !errors.Is(err, ErrNegentropyUnsupported) {
434 return items, err
435 }
436 t.rememberLegacy(target)
437 return t.QueryPaginated(ctx, target, filter)
438}
439
440func (t *NostrTransport) knownLegacy(target string) bool {
441 if t.LegacyCache != nil && t.LegacyCache.known(target) {
442 return true
443 }
444 t.legacyMu.Lock()
445 defer t.legacyMu.Unlock()
446 _, ok := t.legacy[target]
447 return ok
448}
449
450func (t *NostrTransport) rememberLegacy(target string) {
451 if t.LegacyCache != nil {
452 t.LegacyCache.remember(target)
453 return
454 }
455 t.legacyMu.Lock()
456 defer t.legacyMu.Unlock()
457 if t.legacy == nil {
458 t.legacy = make(map[string]struct{})
459 }
460 // Bound the instance-local compatibility cache so untrusted relay URLs
461 // cannot accumulate indefinitely. Once full, an unremembered peer simply
462 // receives another bounded probe on a later sync.
463 if len(t.legacy) < 64 {
464 t.legacy[target] = struct{}{}
465 }
466}
467
468// QueryPaginated reads a bounded history window. It uses the oldest event in
469// each page as the next upper bound, deduplicating events because relays may
470// include the boundary event in their response.
471func (t *NostrTransport) QueryPaginated(ctx context.Context, target string, filter event.Filter) ([]event.Event, error) {
472 result := make([]event.Event, 0, pullPageSize)
473 seen := make(map[string]struct{})
474 pageFilter := filter
475 for page := 0; page < pullPageLimit; page++ {
476 if err := ctx.Err(); err != nil {
477 return result, err
478 }
479 limit := pullPageSize
480 pageFilter.Limit = &limit
481 items, err := t.Query(ctx, target, pageFilter)
482 added := 0
483 for _, item := range items {
484 if _, ok := seen[item.ID]; ok {
485 continue
486 }
487 seen[item.ID] = struct{}{}
488 result = append(result, item)
489 added++
490 }
491 if len(result) >= pullEventLimit {
492 return result, ErrPullIncomplete
493 }
494 if err != nil {
495 return result, err
496 }
497 if len(items) == 0 {
498 return result, nil
499 }
500 oldest := items[0].CreatedAt
501 for _, item := range items {
502 if item.CreatedAt < oldest {
503 oldest = item.CreatedAt
504 }
505 }
506 // The inclusive boundary is an overlap probe. If it yields no new
507 // events, the relay has no older matching events available through this
508 // cursor; a short response is therefore not treated as definitive by
509 // itself.
510 if page > 0 && added == 0 {
511 allAtBoundary := true
512 for _, item := range items {
513 if item.CreatedAt != oldest {
514 allAtBoundary = false
515 break
516 }
517 }
518 if allAtBoundary && len(items) >= pullPageSize {
519 return result, ErrPullIncomplete
520 }
521 return result, nil
522 }
523 if oldest <= 0 {
524 return result, nil
525 }
526 // Keep the boundary inclusive. A relay may cap every response below
527 // our requested limit, and a strict cursor would silently drop events
528 // sharing the oldest timestamp. Deduplication makes the overlap safe.
529 until := oldest
530 pageFilter.Until = &until
531 }
532 return result, ErrPullIncomplete
533}
534
535// QueryNegentropy reconciles a local index with a remote relay, then fetches
536// only IDs the local side lacks. It is optional at the PullTransport boundary
537// so ordinary REQ remains a compatible fallback.
538func (t *NostrTransport) QueryNegentropy(ctx context.Context, target string, filter event.Filter, local []syncprotocol.Item) ([]event.Event, error) {
539 session, err := syncprotocol.NewSession(local, true)
540 if err != nil {
541 return nil, err
542 }
543 start, err := session.Start()
544 if err != nil {
545 return nil, err
546 }
547 socket, _, err := t.dial(ctx, target)
548 if err != nil {
549 return nil, err
550 }
551 defer socket.Close(nil)
552 id := fmt.Sprintf("tiny-neg-%d", t.sequence.Add(1))
553 filter.Tags = ensureTags(filter.Tags)
554 open, err := json.Marshal([]any{"NEG-OPEN", id, filter, start})
555 if err != nil {
556 return nil, err
557 }
558 if err := socket.Write(ctx, open); err != nil {
559 return nil, err
560 }
561 var needed []string
562 progress := false
563 deadline, cancel := context.WithTimeout(ctx, t.timeout())
564 defer cancel()
565 for {
566 data, err := socket.Read(deadline)
567 if err != nil {
568 // Some legacy relays accept the websocket but silently ignore
569 // NEG-OPEN. A bounded probe timeout is the only safe downgrade; a
570 // caller cancellation or an authenticated/protocol response must
571 // remain an error.
572 if errors.Is(err, context.DeadlineExceeded) && !progress && ctx.Err() == nil {
573 return nil, ErrNegentropyUnsupported
574 }
575 return nil, err
576 }
577 var raw []json.RawMessage
578 if json.Unmarshal(data, &raw) != nil || len(raw) < 2 {
579 continue
580 }
581 var kind, messageID, body string
582 _ = json.Unmarshal(raw[0], &kind)
583 _ = json.Unmarshal(raw[1], &messageID)
584 if len(raw) > 2 {
585 _ = json.Unmarshal(raw[2], &body)
586 }
587 if kind == "AUTH" {
588 return nil, errors.New("replication: remote relay requires authentication")
589 }
590 if messageID != id {
591 continue
592 }
593 // A responder may close immediately when its authorized snapshot is
594 // already identical. Treat that as a completed empty reconciliation;
595 // waiting for a NEG-MSG that will never arrive turns a successful NIP-77
596 // no-op sync into a timeout.
597 if kind == "NEG-CLOSE" {
598 progress = true
599 break
600 }
601 if kind == "CLOSED" {
602 return nil, errors.New("replication: remote negentropy closed")
603 }
604 if kind == "NEG-ERR" {
605 if negentropyUnsupported(body) {
606 return nil, ErrNegentropyUnsupported
607 }
608 return nil, fmt.Errorf("replication: remote negentropy rejected: %s", body)
609 }
610 if kind != "NEG-MSG" {
611 continue
612 }
613 progress = true
614 result, err := session.Reconcile(body)
615 if err != nil {
616 return nil, err
617 }
618 needed = append(needed, result.Need...)
619 if len(needed) > pullEventLimit {
620 return nil, ErrPullIncomplete
621 }
622 if result.Done {
623 closeMessage, _ := json.Marshal([]any{"NEG-CLOSE", id})
624 _ = socket.Write(ctx, closeMessage)
625 break
626 }
627 reply, err := json.Marshal([]any{"NEG-MSG", id, result.Response})
628 if err != nil {
629 return nil, err
630 }
631 if err := socket.Write(ctx, reply); err != nil {
632 return nil, err
633 }
634 }
635 if len(needed) == 0 {
636 return nil, nil
637 }
638 return t.queryNegentropyIDs(ctx, target, filter, needed)
639}
640
641func negentropyUnsupported(reason string) bool {
642 reason = strings.ToLower(strings.TrimSpace(reason))
643 if strings.HasPrefix(reason, "unsupported") || strings.HasPrefix(reason, "error: unsupported") {
644 return true
645 }
646 switch reason {
647 case "unsupported", "not supported", "negentropy unsupported", "nip-77 unsupported":
648 return true
649 default:
650 return false
651 }
652}
653
654const negentropyFetchBatch = 500
655
656func (t *NostrTransport) queryNegentropyIDs(ctx context.Context, target string, filter event.Filter, ids []string) ([]event.Event, error) {
657 result := make([]event.Event, 0, len(ids))
658 seen := make(map[string]struct{}, len(ids))
659 for start := 0; start < len(ids); start += negentropyFetchBatch {
660 end := start + negentropyFetchBatch
661 if end > len(ids) {
662 end = len(ids)
663 }
664 batch := append([]string(nil), ids[start:end]...)
665 request := filter
666 request.IDs = batch
667 limit := len(batch)
668 request.Limit = &limit
669 events, err := t.Query(ctx, target, request)
670 if err != nil {
671 for _, candidate := range events {
672 if _, ok := seen[candidate.ID]; ok {
673 continue
674 }
675 seen[candidate.ID] = struct{}{}
676 result = append(result, candidate)
677 }
678 return result, err
679 }
680 allowed := make(map[string]struct{}, len(batch))
681 for _, id := range batch {
682 allowed[id] = struct{}{}
683 }
684 for _, candidate := range events {
685 if _, ok := allowed[candidate.ID]; !ok {
686 continue
687 }
688 if !event.Matches(filter, candidate) {
689 continue
690 }
691 if _, ok := seen[candidate.ID]; ok {
692 continue
693 }
694 seen[candidate.ID] = struct{}{}
695 result = append(result, candidate)
696 }
697 }
698 if len(seen) != len(uniqueIDs(ids)) {
699 return result, ErrPullIncomplete
700 }
701 return result, nil
702}
703
704func uniqueIDs(ids []string) map[string]struct{} {
705 unique := make(map[string]struct{}, len(ids))
706 for _, id := range ids {
707 unique[id] = struct{}{}
708 }
709 return unique
710}
711
712func (t *NostrTransport) timeout() time.Duration {
713 if t.Timeout > 0 {
714 return t.Timeout
715 }
716 return 20 * time.Second
717}
718
719func (t *NostrTransport) Query(ctx context.Context, target string, filter event.Filter) ([]event.Event, error) {
720 socket, _, err := t.dial(ctx, target)
721 if err != nil {
722 return nil, err
723 }
724 defer socket.Close(nil)
725 sub := fmt.Sprintf("tiny-pull-%d", t.sequence.Add(1))
726 filter.Tags = ensureTags(filter.Tags)
727 message, err := json.Marshal([]any{"REQ", sub, filter})
728 if err != nil {
729 return nil, fmt.Errorf("encode query: %w", err)
730 }
731 if err := socket.Write(ctx, message); err != nil {
732 return nil, err
733 }
734 return readEvents(ctx, socket, sub, t.timeout(), filter)
735}
736
737func (t *NostrTransport) Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error) {
738 socket, _, err := t.dial(ctx, target)
739 if err != nil {
740 return DeliveryResult{}, err
741 }
742 defer socket.Close(nil)
743 message, err := json.Marshal([]any{"EVENT", e})
744 if err != nil {
745 return DeliveryResult{}, err
746 }
747 if err := socket.Write(ctx, message); err != nil {
748 return DeliveryResult{}, err
749 }
750 deadline, cancel := context.WithTimeout(ctx, t.timeout())
751 defer cancel()
752 for {
753 data, err := socket.Read(deadline)
754 if err != nil {
755 return DeliveryResult{}, err
756 }
757 var message []json.RawMessage
758 if json.Unmarshal(data, &message) != nil || len(message) < 4 {
759 continue
760 }
761 var kind, id, text string
762 var accepted bool
763 _ = json.Unmarshal(message[0], &kind)
764 _ = json.Unmarshal(message[1], &id)
765 _ = json.Unmarshal(message[2], &accepted)
766 _ = json.Unmarshal(message[3], &text)
767 if kind == "OK" && id == e.ID {
768 return DeliveryResult{Accepted: accepted, Message: text}, nil
769 }
770 }
771}
772
773func (t *NostrTransport) dial(ctx context.Context, target string) (Socket, *http.Response, error) {
774 dialer := t.Dialer
775 if dialer == nil {
776 dialer = WebsocketDialer{}
777 }
778 return dialer.Dial(ctx, target)
779}
780
781func readEvents(ctx context.Context, socket Socket, subscription string, timeout time.Duration, filter event.Filter) ([]event.Event, error) {
782 deadline, cancel := context.WithTimeout(ctx, timeout)
783 defer cancel()
784 items := make([]event.Event, 0)
785 for {
786 data, err := socket.Read(deadline)
787 if err != nil {
788 return items, err
789 }
790 var raw []json.RawMessage
791 if err := json.Unmarshal(data, &raw); err != nil || len(raw) < 2 {
792 continue
793 }
794 var kind, id string
795 _ = json.Unmarshal(raw[0], &kind)
796 _ = json.Unmarshal(raw[1], &id)
797 switch kind {
798 case "EVENT":
799 if id != subscription || len(raw) < 3 {
800 continue
801 }
802 var item event.Event
803 if json.Unmarshal(raw[2], &item) == nil && event.Validate(item) == nil && event.Matches(filter, item) {
804 items = append(items, item)
805 if len(items) > pullEventLimit {
806 return items[:pullEventLimit], ErrPullIncomplete
807 }
808 }
809 case "EOSE":
810 if id == subscription {
811 return items, nil
812 }
813 case "CLOSED":
814 if id == subscription {
815 return items, errors.New("replication: remote query closed")
816 }
817 case "AUTH":
818 return items, errors.New("replication: remote relay requires authentication")
819 }
820 }
821}
822
823func ensureTags(tags map[string][]string) map[string][]string {
824 if tags != nil {
825 return tags
826 }
827 return map[string][]string{}
828}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs