Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4daeb8737c | |||
| 7ab69cbc60 |
@@ -2,9 +2,7 @@ package nostr
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"hash"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
|
||||
"github.com/mailru/easyjson"
|
||||
"github.com/templexxx/xhex"
|
||||
@@ -28,17 +26,10 @@ func (evt Event) String() string {
|
||||
|
||||
// GetID serializes and returns the event ID as a string.
|
||||
func (evt Event) GetID() ID {
|
||||
var id ID
|
||||
evt.serializedHash(&id)
|
||||
return id
|
||||
return sha256.Sum256(evt.Serialize())
|
||||
}
|
||||
|
||||
// SetID calculates and sets the id to the event in a single operation.
|
||||
func (evt *Event) SetID() {
|
||||
evt.serializedHash(&evt.ID)
|
||||
}
|
||||
|
||||
// CheckID checks if the implied ID matches the currently assigned ID.
|
||||
// CheckID checks if the implied ID matches the given ID more efficiently.
|
||||
func (evt Event) CheckID() bool {
|
||||
return evt.GetID() == evt.ID
|
||||
}
|
||||
@@ -47,56 +38,17 @@ func (evt Event) CheckID() bool {
|
||||
func (evt Event) Serialize() []byte {
|
||||
// the serialization process is just putting everything into a JSON array
|
||||
// so the order is kept. See NIP-01
|
||||
dst := make([]byte, 0, 100+len(evt.Content)+len(evt.Tags)*80)
|
||||
return evt.appendSerialized(dst)
|
||||
}
|
||||
dst := make([]byte, 4+64, 100+len(evt.Content)+len(evt.Tags)*80)
|
||||
|
||||
var escTable [256]bool
|
||||
|
||||
// pre-built escape sequences; index by the offending byte.
|
||||
var escSeq [256][2]byte
|
||||
|
||||
// pre-built []byte slices for hash.Write calls (no per-call allocation).
|
||||
var escSlice [256][]byte
|
||||
|
||||
var (
|
||||
jsonQuote = []byte{'"'}
|
||||
serializedStart = []byte(`[0,"`)
|
||||
serializedPubkeyEnd = []byte(`",`)
|
||||
serializedTagsEnd = []byte("],")
|
||||
serializedTagStart = []byte{'['}
|
||||
serializedTagEnd = []byte{']'}
|
||||
serializedComma = []byte{','}
|
||||
serializedEnd = []byte{']'}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, b := range []byte{'"', '\\', '\n', '\r', '\t'} {
|
||||
escTable[b] = true
|
||||
}
|
||||
|
||||
escSeq['"'] = [2]byte{'\\', '"'}
|
||||
escSeq['\\'] = [2]byte{'\\', '\\'}
|
||||
escSeq['\n'] = [2]byte{'\\', 'n'}
|
||||
escSeq['\r'] = [2]byte{'\\', 'r'}
|
||||
escSeq['\t'] = [2]byte{'\\', 't'}
|
||||
for b, seq := range escSeq {
|
||||
if escTable[b] {
|
||||
escSlice[b] = seq[:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (evt Event) appendSerialized(dst []byte) []byte {
|
||||
start := len(dst)
|
||||
dst = append(dst, `[0,"`...)
|
||||
dst = append(dst, make([]byte, 64)...)
|
||||
xhex.Encode(dst[start+4:start+4+64], evt.PubKey[:])
|
||||
// the header portion is easy to serialize
|
||||
// [0,"pubkey",created_at,kind,[
|
||||
copy(dst, `[0,"`)
|
||||
xhex.Encode(dst[4:4+64], evt.PubKey[:]) // there will always be such capacity
|
||||
dst = append(dst, `",`...)
|
||||
dst = strconv.AppendInt(dst, int64(evt.CreatedAt), 10)
|
||||
dst = append(dst, ',')
|
||||
dst = strconv.AppendUint(dst, uint64(evt.Kind), 10)
|
||||
dst = append(dst, ',')
|
||||
dst = append(dst, strconv.FormatInt(int64(evt.CreatedAt), 10)...)
|
||||
dst = append(dst, `,`...)
|
||||
dst = append(dst, strconv.FormatUint(uint64(evt.Kind), 10)...)
|
||||
dst = append(dst, `,`...)
|
||||
|
||||
// tags
|
||||
dst = append(dst, '[')
|
||||
@@ -110,167 +62,15 @@ func (evt Event) appendSerialized(dst []byte) []byte {
|
||||
if i > 0 {
|
||||
dst = append(dst, ',')
|
||||
}
|
||||
dst = appendJSONString(dst, s)
|
||||
dst = escapeString(dst, s)
|
||||
}
|
||||
dst = append(dst, ']')
|
||||
}
|
||||
dst = append(dst, "],"...)
|
||||
|
||||
// content needs to be escaped in general as it is user generated.
|
||||
dst = appendJSONString(dst, evt.Content)
|
||||
dst = escapeString(dst, evt.Content)
|
||||
dst = append(dst, ']')
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func (evt Event) serializedHash(dst *ID) {
|
||||
h := sha256.New()
|
||||
h.Write(serializedStart)
|
||||
|
||||
var pubkeyHex [64]byte
|
||||
xhex.Encode(pubkeyHex[:], evt.PubKey[:])
|
||||
h.Write(pubkeyHex[:])
|
||||
h.Write(serializedPubkeyEnd)
|
||||
|
||||
var numBuf [20]byte
|
||||
b := strconv.AppendInt(numBuf[:0], int64(evt.CreatedAt), 10)
|
||||
h.Write(b)
|
||||
h.Write(serializedComma)
|
||||
b = strconv.AppendUint(numBuf[:0], uint64(evt.Kind), 10)
|
||||
h.Write(b)
|
||||
h.Write(serializedComma)
|
||||
|
||||
h.Write(serializedTagStart)
|
||||
for i, tag := range evt.Tags {
|
||||
if i > 0 {
|
||||
h.Write(serializedComma)
|
||||
}
|
||||
h.Write(serializedTagStart)
|
||||
for j, s := range tag {
|
||||
if j > 0 {
|
||||
h.Write(serializedComma)
|
||||
}
|
||||
writeJSONString(h, s)
|
||||
}
|
||||
h.Write(serializedTagEnd)
|
||||
}
|
||||
h.Write(serializedTagsEnd)
|
||||
|
||||
writeJSONString(h, evt.Content)
|
||||
h.Write(serializedEnd)
|
||||
|
||||
h.Sum((*dst)[:0])
|
||||
}
|
||||
|
||||
// ── SWAR helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
// hasSpecial returns non-zero if any byte in w is one of: \t 0x09, \n 0x0A,
|
||||
// " 0x22, \ 0x5C. Uses the classic "hasvalue" bit-trick — no branches, no
|
||||
// memory, pure ALU. Works regardless of endianness because we only care
|
||||
// whether a match exists, not where.
|
||||
//
|
||||
//go:nosplit
|
||||
func hasSpecial(w uint64) bool {
|
||||
match := func(w, v uint64) uint64 {
|
||||
x := w ^ (0x0101010101010101 * v)
|
||||
return (x - 0x0101010101010101) & ^x & 0x8080808080808080
|
||||
}
|
||||
return match(w, 0x09)|match(w, 0x0A)|match(w, 0x0D)|match(w, 0x22)|match(w, 0x5C) != 0
|
||||
}
|
||||
|
||||
func appendJSONString(dst []byte, s string) []byte {
|
||||
dst = append(dst, '"')
|
||||
|
||||
n := len(s)
|
||||
if n == 0 {
|
||||
return append(dst, '"')
|
||||
}
|
||||
|
||||
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
|
||||
start, i := 0, 0
|
||||
|
||||
// consume 8 bytes at a time;
|
||||
// if the whole word is clean, advance without touching dst at all;
|
||||
// but when a word is dirty, fall back to the byte loop only for that 8-byte window
|
||||
for i+8 <= n {
|
||||
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
|
||||
if hasSpecial(w) {
|
||||
for end := i + 8; i < end; i++ {
|
||||
if escTable[s[i]] {
|
||||
// append everything since the start or the last time we did this up to here
|
||||
dst = append(dst, s[start:i]...)
|
||||
|
||||
// append this special sequence
|
||||
seq := escSeq[s[i]]
|
||||
dst = append(dst, seq[0], seq[1])
|
||||
|
||||
// set this as a checkpoint
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i += 8
|
||||
}
|
||||
}
|
||||
|
||||
// scalar tail for the remaining <8 bytes (same logic used for the hasSpecial branch above)
|
||||
for ; i < n; i++ {
|
||||
if escTable[s[i]] {
|
||||
dst = append(dst, s[start:i]...)
|
||||
seq := escSeq[s[i]]
|
||||
dst = append(dst, seq[0], seq[1])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// add the remaining chunk (in a string without any specials this will add everything at once)
|
||||
dst = append(dst, s[start:]...)
|
||||
|
||||
return append(dst, '"')
|
||||
}
|
||||
|
||||
func writeJSONString(h hash.Hash, s string) {
|
||||
h.Write(jsonQuote)
|
||||
|
||||
n := len(s)
|
||||
if n == 0 {
|
||||
h.Write(jsonQuote)
|
||||
return
|
||||
}
|
||||
|
||||
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
|
||||
start, i := 0, 0
|
||||
|
||||
for i+8 <= n {
|
||||
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
|
||||
// apply same logic as of appendJSONString()
|
||||
if hasSpecial(w) {
|
||||
for end := i + 8; i < end; i++ {
|
||||
if escTable[s[i]] {
|
||||
if i > start {
|
||||
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
|
||||
}
|
||||
h.Write(escSlice[s[i]])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i += 8
|
||||
}
|
||||
}
|
||||
|
||||
for ; i < n; i++ {
|
||||
if escTable[s[i]] {
|
||||
if i > start {
|
||||
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
|
||||
}
|
||||
h.Write(escSlice[s[i]])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
if start < n {
|
||||
h.Write(unsafe.Slice(unsafe.StringData(s[start:]), len(s)-start))
|
||||
}
|
||||
h.Write(jsonQuote)
|
||||
}
|
||||
|
||||
+18
-48
@@ -1,12 +1,8 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -106,49 +102,23 @@ func TestIDCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEventVerifySignatureJSONL(b *testing.B) {
|
||||
events := loadBenchmarkEvents(b)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, evt := range events {
|
||||
if !evt.VerifySignature() {
|
||||
b.Fatal("signature verification failed")
|
||||
}
|
||||
}
|
||||
func BenchmarkIDCheck(b *testing.B) {
|
||||
evt := Event{
|
||||
CreatedAt: Timestamp(rand.Int64N(9999999)),
|
||||
Content: fmt.Sprintf("hello"),
|
||||
Tags: Tags{},
|
||||
}
|
||||
}
|
||||
|
||||
func loadBenchmarkEvents(b *testing.B) []Event {
|
||||
b.Helper()
|
||||
|
||||
f, err := os.Open("testdata/events.jsonl")
|
||||
require.NoError(b, err)
|
||||
b.Cleanup(func() { _ = f.Close() })
|
||||
|
||||
r := bufio.NewReader(f)
|
||||
events := make([]Event, 0, 1024)
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
require.NoError(b, err)
|
||||
}
|
||||
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) != 0 {
|
||||
var evt Event
|
||||
require.NoError(b, json.Unmarshal(line, &evt))
|
||||
require.True(b, evt.VerifySignature(), "fixture contains invalid signature")
|
||||
events = append(events, evt)
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotEmpty(b, events)
|
||||
return events
|
||||
evt.Sign(Generate())
|
||||
|
||||
b.Run("naïve", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = evt.GetID() == evt.ID
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("big brain", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = evt.CheckID()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+23
-33
@@ -202,12 +202,9 @@ func (b *BleveBackend) Init() error {
|
||||
}
|
||||
|
||||
b.index = index
|
||||
|
||||
if len(b.Languages) >= 2 {
|
||||
b.detector = lingua.NewLanguageDetectorBuilder().
|
||||
FromLanguages(b.Languages...).
|
||||
Build()
|
||||
}
|
||||
b.detector = lingua.NewLanguageDetectorBuilder().
|
||||
FromLanguages(b.Languages...).
|
||||
Build()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -251,25 +248,25 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
|
||||
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
|
||||
references = append(references, pm.NIP05)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 {
|
||||
continue
|
||||
}
|
||||
switch tag[0] {
|
||||
case "comment", "name", "title", "about", "description":
|
||||
evt.Content += "\n\n" + tag[1]
|
||||
case "e":
|
||||
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
|
||||
references = append(references, ptr.AsTagReference())
|
||||
case 9802:
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 {
|
||||
continue
|
||||
}
|
||||
case "a":
|
||||
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
|
||||
references = append(references, ptr.AsTagReference())
|
||||
switch tag[0] {
|
||||
case "comment":
|
||||
evt.Content += "\n\n" + tag[1]
|
||||
case "e":
|
||||
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
|
||||
references = append(references, ptr.AsTagReference())
|
||||
}
|
||||
case "a":
|
||||
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
|
||||
references = append(references, ptr.AsTagReference())
|
||||
}
|
||||
case "r":
|
||||
references = append(references, tag[1])
|
||||
}
|
||||
case "r":
|
||||
references = append(references, tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,16 +291,9 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
|
||||
}
|
||||
|
||||
indexableContent := content.String()
|
||||
|
||||
var lang lingua.Language
|
||||
if len(b.Languages) == 1 {
|
||||
lang = b.Languages[0]
|
||||
} else {
|
||||
var ok bool
|
||||
lang, ok = b.detector.DetectLanguageOf(indexableContent)
|
||||
if !ok {
|
||||
lang = lingua.English
|
||||
}
|
||||
lang, ok := b.detector.DetectLanguageOf(indexableContent)
|
||||
if !ok {
|
||||
lang = lingua.English
|
||||
}
|
||||
|
||||
var analyzerLangCode string
|
||||
|
||||
@@ -42,8 +42,6 @@ func (il *IndexingLayer) ComputeStats(opts StatsOptions) (EventStats, error) {
|
||||
}
|
||||
|
||||
err := il.lmdbEnv.View(func(txn *lmdb.Txn) error {
|
||||
txn.RawRead = true
|
||||
|
||||
cursor, err := txn.OpenCursor(il.indexPubkeyKind)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -40,7 +40,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.7
|
||||
fiatjaf.com/lib v0.3.6
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/go-git/go-git/v5 v5.16.3
|
||||
github.com/pemistahl/lingua-go v1.4.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
|
||||
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
|
||||
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
|
||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
|
||||
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
|
||||
|
||||
+40
@@ -92,6 +92,46 @@ func similarPublicKey(as, bs []PubKey) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Escaping strings for JSON encoding according to RFC8259.
|
||||
// Also encloses result in quotation marks "".
|
||||
func escapeString(dst []byte, s string) []byte {
|
||||
dst = append(dst, '"')
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '"':
|
||||
// quotation mark
|
||||
dst = append(dst, []byte{'\\', '"'}...)
|
||||
case c == '\\':
|
||||
// reverse solidus
|
||||
dst = append(dst, []byte{'\\', '\\'}...)
|
||||
case c >= 0x20:
|
||||
// default, rest below are control chars
|
||||
dst = append(dst, c)
|
||||
case c == 0x08:
|
||||
dst = append(dst, []byte{'\\', 'b'}...)
|
||||
case c < 0x09:
|
||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...)
|
||||
case c == 0x09:
|
||||
dst = append(dst, []byte{'\\', 't'}...)
|
||||
case c == 0x0a:
|
||||
dst = append(dst, []byte{'\\', 'n'}...)
|
||||
case c == 0x0c:
|
||||
dst = append(dst, []byte{'\\', 'f'}...)
|
||||
case c == 0x0d:
|
||||
dst = append(dst, []byte{'\\', 'r'}...)
|
||||
case c < 0x10:
|
||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...)
|
||||
case c < 0x1a:
|
||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...)
|
||||
case c < 0x20:
|
||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...)
|
||||
}
|
||||
}
|
||||
dst = append(dst, '"')
|
||||
return dst
|
||||
}
|
||||
|
||||
func subIdToSerial(subId string) int64 {
|
||||
n := strings.Index(subId, ":")
|
||||
if n < 0 || n > len(subId) {
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ func (rl *Relay) handleNormal(ctx context.Context, evt nostr.Event) (skipBroadca
|
||||
} else {
|
||||
// otherwise it's a replaceable
|
||||
if nil != rl.ReplaceEvent {
|
||||
if err := rl.ReplaceEvent(ctx, evt); err != nil {
|
||||
if _, err := rl.ReplaceEvent(ctx, evt); err != nil {
|
||||
switch err {
|
||||
case eventstore.ErrDupEvent:
|
||||
return true, nil
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDispatcherCandidates(t *testing.T) {
|
||||
d := newDispatcher()
|
||||
|
||||
d.addSubscription(subscription{
|
||||
id: "...",
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9},
|
||||
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||
},
|
||||
})
|
||||
d.addSubscription(subscription{
|
||||
id: "...",
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{11},
|
||||
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||
},
|
||||
})
|
||||
d.addSubscription(subscription{
|
||||
id: "...",
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9, 11, 1111},
|
||||
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||
},
|
||||
})
|
||||
d.addSubscription(subscription{
|
||||
id: "...",
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9, 11, 1111},
|
||||
Tags: nostr.TagMap{"h": []string{"bbb"}},
|
||||
},
|
||||
})
|
||||
d.addSubscription(subscription{
|
||||
id: "...",
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9, 11, 1111},
|
||||
Authors: []nostr.PubKey{
|
||||
nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
matched := 0
|
||||
for range d.candidates(nostr.Event{
|
||||
PubKey: nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||
ID: nostr.MustIDFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||
Kind: 9,
|
||||
CreatedAt: nostr.Now(),
|
||||
Content: "hello",
|
||||
Tags: nostr.Tags{
|
||||
{"h", "aaa"},
|
||||
},
|
||||
}) {
|
||||
matched++
|
||||
}
|
||||
|
||||
require.Equal(t, 3, matched)
|
||||
}
|
||||
|
||||
func FuzzDispatcherCandidates(f *testing.F) {
|
||||
f.Add(1, 1, uint8(8), uint8(16))
|
||||
f.Add(2, 3, uint8(32), uint8(32))
|
||||
|
||||
f.Fuzz(func(t *testing.T, seed int, advance int, ops uint8, checks uint8) {
|
||||
d := newDispatcher()
|
||||
state := fuzzState{value: seed, advance: advance}
|
||||
|
||||
active := make(map[int]subscription)
|
||||
activeSSIDs := make([]int, 0, int(ops))
|
||||
nextSubID := 0
|
||||
|
||||
steps := int(ops) + 1
|
||||
for range steps {
|
||||
if len(activeSSIDs) == 0 || state.next(10) != 0 {
|
||||
nextSubID++
|
||||
sub := subscription{
|
||||
id: strconv.Itoa(nextSubID),
|
||||
filter: fuzzDispatcherFilter(&state),
|
||||
}
|
||||
|
||||
ssid := d.addSubscription(sub)
|
||||
|
||||
active[ssid] = sub
|
||||
activeSSIDs = append(activeSSIDs, ssid)
|
||||
} else {
|
||||
idx := state.next(len(activeSSIDs))
|
||||
ssid := activeSSIDs[idx]
|
||||
|
||||
d.removeSubscription(ssid)
|
||||
|
||||
delete(active, ssid)
|
||||
activeSSIDs = append(activeSSIDs[:idx], activeSSIDs[idx+1:]...)
|
||||
}
|
||||
|
||||
for range int(checks%7) + 1 {
|
||||
event := fuzzDispatcherEvent(&state)
|
||||
|
||||
expected := expectedDispatcherCandidates(active, event)
|
||||
actual := collectedDispatcherCandidates(&d, event)
|
||||
|
||||
require.Equalf(t, expected, actual, "seed=%d advance=%d event=%s active=%v", seed, advance, event.String(), active)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssid := range activeSSIDs {
|
||||
d.removeSubscription(ssid)
|
||||
delete(active, ssid)
|
||||
}
|
||||
|
||||
require.Empty(t, collectedDispatcherCandidates(&d, fuzzDispatcherEvent(&state)))
|
||||
})
|
||||
}
|
||||
|
||||
type fuzzState struct {
|
||||
value int
|
||||
advance int
|
||||
}
|
||||
|
||||
func (state *fuzzState) next(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
value := state.value % n
|
||||
if value < 0 {
|
||||
value += n
|
||||
}
|
||||
state.value += state.advance
|
||||
return value
|
||||
}
|
||||
|
||||
func fuzzDispatcherFilter(seed *fuzzState) nostr.Filter {
|
||||
filter := nostr.Filter{
|
||||
Authors: fuzzDispatcherAuthors(seed),
|
||||
Kinds: fuzzDispatcherKinds(seed),
|
||||
Tags: fuzzDispatcherTagMap(seed),
|
||||
}
|
||||
|
||||
if seed.next(3) == 0 {
|
||||
since := nostr.Timestamp(seed.next(6))
|
||||
until := since + nostr.Timestamp(seed.next(6))
|
||||
filter.Since = since
|
||||
filter.Until = until
|
||||
} else if seed.next(4) == 0 {
|
||||
filter.Since = nostr.Timestamp(seed.next(8))
|
||||
} else if seed.next(4) == 0 {
|
||||
filter.Until = nostr.Timestamp(seed.next(8))
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func fuzzDispatcherAuthors(seed *fuzzState) []nostr.PubKey {
|
||||
switch seed.next(4) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return []nostr.PubKey{}
|
||||
}
|
||||
|
||||
count := seed.next(3) + 1
|
||||
authors := make([]nostr.PubKey, 0, count)
|
||||
for range count {
|
||||
pk := nostr.PubKey{byte(seed.next(4) + 1)}
|
||||
if !slices.Contains(authors, pk) {
|
||||
authors = append(authors, pk)
|
||||
}
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
func fuzzDispatcherKinds(seed *fuzzState) []nostr.Kind {
|
||||
switch seed.next(4) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return []nostr.Kind{}
|
||||
}
|
||||
|
||||
count := seed.next(3) + 1
|
||||
kinds := make([]nostr.Kind, 0, count)
|
||||
for range count {
|
||||
kind := nostr.Kind(seed.next(5) + 1)
|
||||
if !slices.Contains(kinds, kind) {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func fuzzDispatcherTagMap(seed *fuzzState) nostr.TagMap {
|
||||
if seed.next(3) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := []string{"e", "p", "t"}
|
||||
values := []string{"a", "b", "c", "d"}
|
||||
|
||||
count := seed.next(3)
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make(nostr.TagMap, count)
|
||||
start := seed.next(len(keys))
|
||||
for i := range count {
|
||||
idx := (start + i) % len(keys)
|
||||
valueCount := seed.next(3) + 1
|
||||
entries := make([]string, 0, valueCount)
|
||||
for range valueCount {
|
||||
value := values[seed.next(len(values))]
|
||||
if !slices.Contains(entries, value) {
|
||||
entries = append(entries, value)
|
||||
}
|
||||
}
|
||||
tags[keys[idx]] = entries
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
func fuzzDispatcherEvent(seed *fuzzState) nostr.Event {
|
||||
tags := make(nostr.Tags, 0, seed.next(4))
|
||||
keys := []string{"e", "p", "t"}
|
||||
values := []string{"a", "b", "c", "d"}
|
||||
for range cap(tags) {
|
||||
tags = append(tags, nostr.Tag{keys[seed.next(len(keys))], values[seed.next(len(values))]})
|
||||
}
|
||||
|
||||
return nostr.Event{
|
||||
PubKey: nostr.PubKey{byte(seed.next(4) + 1)},
|
||||
Kind: nostr.Kind(seed.next(5) + 1),
|
||||
CreatedAt: nostr.Timestamp(seed.next(8)),
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
func expectedDispatcherCandidates(active map[int]subscription, event nostr.Event) []string {
|
||||
ids := make([]string, 0, len(active))
|
||||
for _, sub := range active {
|
||||
if sub.filter.Matches(event) {
|
||||
ids = append(ids, sub.id)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func collectedDispatcherCandidates(d *dispatcher, event nostr.Event) []string {
|
||||
ids := make([]string, 0, d.subscriptions.Size())
|
||||
for sub := range d.candidates(event) {
|
||||
ids = append(ids, sub.id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
+2
-4
@@ -43,8 +43,8 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
relayPathMatches := true
|
||||
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
||||
p, err := url.Parse(serviceURL)
|
||||
if rl.ServiceURL != "" {
|
||||
p, err := url.Parse(rl.ServiceURL)
|
||||
if err == nil {
|
||||
relayPathMatches = strings.TrimSuffix(r.URL.Path, "/") == strings.TrimSuffix(p.Path, "/")
|
||||
}
|
||||
@@ -290,8 +290,6 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
ws.WriteJSON(resp)
|
||||
|
||||
case *nostr.ReqEnvelope:
|
||||
rl.removeListenerId(ws, env.SubscriptionID)
|
||||
|
||||
eose := sync.WaitGroup{}
|
||||
eose.Add(len(env.Filters))
|
||||
|
||||
|
||||
+64
-141
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"iter"
|
||||
"sync"
|
||||
|
||||
"fiatjaf.com/lib/set"
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -32,27 +31,19 @@ type subscription struct {
|
||||
}
|
||||
|
||||
type dispatcher struct {
|
||||
serial int
|
||||
subscriptions *xsync.MapOf[int, subscription]
|
||||
byAuthor *xsync.MapOf[nostr.PubKey, set.Set[int]]
|
||||
byKind *xsync.MapOf[nostr.Kind, set.Set[int]]
|
||||
fallbackTags set.Set[int]
|
||||
fallbackNothing set.Set[int]
|
||||
}
|
||||
|
||||
var setPool = sync.Pool{
|
||||
New: func() any {
|
||||
return set.NewEmptySliceSetReusing[int](make([]int, 0, 10))
|
||||
},
|
||||
serial int
|
||||
subscriptions *xsync.MapOf[int, subscription]
|
||||
byAuthor map[nostr.PubKey]set.Set[int]
|
||||
byKind map[nostr.Kind]set.Set[int]
|
||||
fallback set.Set[int]
|
||||
}
|
||||
|
||||
func newDispatcher() dispatcher {
|
||||
return dispatcher{
|
||||
subscriptions: xsync.NewMapOf[int, subscription](),
|
||||
byAuthor: xsync.NewMapOf[nostr.PubKey, set.Set[int]](),
|
||||
byKind: xsync.NewMapOf[nostr.Kind, set.Set[int]](),
|
||||
fallbackTags: setPool.Get().(set.Set[int]),
|
||||
fallbackNothing: setPool.Get().(set.Set[int]),
|
||||
subscriptions: xsync.NewMapOf[int, subscription](),
|
||||
byAuthor: make(map[nostr.PubKey]set.Set[int]),
|
||||
byKind: make(map[nostr.Kind]set.Set[int]),
|
||||
fallback: set.NewSliceSet[int](),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,128 +57,93 @@ func (d *dispatcher) addSubscription(sub subscription) int {
|
||||
if sub.filter.Authors != nil {
|
||||
indexed = true
|
||||
for _, author := range sub.filter.Authors {
|
||||
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||
if !loaded {
|
||||
s = setPool.Get().(set.Set[int])
|
||||
}
|
||||
s.Add(ssid)
|
||||
return s, false
|
||||
})
|
||||
s, ok := d.byAuthor[author]
|
||||
if !ok {
|
||||
s = set.NewSliceSet[int]()
|
||||
d.byAuthor[author] = s
|
||||
}
|
||||
s.Add(ssid)
|
||||
}
|
||||
}
|
||||
|
||||
if sub.filter.Kinds != nil {
|
||||
indexed = true
|
||||
for _, kind := range sub.filter.Kinds {
|
||||
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||
if !loaded {
|
||||
s = setPool.Get().(set.Set[int])
|
||||
}
|
||||
s.Add(ssid)
|
||||
return s, false
|
||||
})
|
||||
s, ok := d.byKind[kind]
|
||||
if !ok {
|
||||
s = set.NewSliceSet[int]()
|
||||
d.byKind[kind] = s
|
||||
}
|
||||
s.Add(ssid)
|
||||
}
|
||||
}
|
||||
|
||||
if !indexed {
|
||||
if sub.filter.Tags != nil {
|
||||
d.fallbackTags.Add(ssid)
|
||||
} else {
|
||||
d.fallbackNothing.Add(ssid)
|
||||
}
|
||||
d.fallback.Add(ssid)
|
||||
}
|
||||
|
||||
return ssid
|
||||
}
|
||||
|
||||
func (d *dispatcher) removeSubscription(ssid int) nostr.Filter {
|
||||
var filter nostr.Filter
|
||||
func (d *dispatcher) removeSubscription(ssid int) {
|
||||
sub, ok := d.subscriptions.LoadAndDelete(ssid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
d.subscriptions.Compute(ssid, func(sub subscription, loaded bool) (subscription, bool) {
|
||||
indexed := false
|
||||
|
||||
filter = sub.filter
|
||||
|
||||
if sub.filter.Authors != nil {
|
||||
indexed = true
|
||||
for _, author := range sub.filter.Authors {
|
||||
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||
if !loaded {
|
||||
return s, true
|
||||
}
|
||||
s.Remove(ssid)
|
||||
|
||||
delete := s.Len() == 0
|
||||
if delete {
|
||||
setPool.Put(s)
|
||||
}
|
||||
return s, delete
|
||||
})
|
||||
indexed := false
|
||||
if sub.filter.Authors != nil {
|
||||
indexed = true
|
||||
for _, author := range sub.filter.Authors {
|
||||
s, ok := d.byAuthor[author]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.Remove(ssid)
|
||||
if s.Len() == 0 {
|
||||
delete(d.byAuthor, author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sub.filter.Kinds != nil {
|
||||
indexed = true
|
||||
for _, kind := range sub.filter.Kinds {
|
||||
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||
if !loaded {
|
||||
return s, true
|
||||
}
|
||||
s.Remove(ssid)
|
||||
|
||||
delete := s.Len() == 0
|
||||
if delete {
|
||||
setPool.Put(s)
|
||||
}
|
||||
return s, delete
|
||||
})
|
||||
if sub.filter.Kinds != nil {
|
||||
indexed = true
|
||||
for _, kind := range sub.filter.Kinds {
|
||||
s, ok := d.byKind[kind]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.Remove(ssid)
|
||||
if s.Len() == 0 {
|
||||
delete(d.byKind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !indexed {
|
||||
if sub.filter.Tags != nil {
|
||||
d.fallbackTags.Remove(ssid)
|
||||
} else {
|
||||
d.fallbackNothing.Remove(ssid)
|
||||
}
|
||||
}
|
||||
|
||||
return sub, true
|
||||
})
|
||||
|
||||
return filter
|
||||
if !indexed {
|
||||
d.fallback.Remove(ssid)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
||||
return func(yield func(subscription) bool) {
|
||||
authorSubs, hasAuthorSubs := d.byAuthor.Load(event.PubKey)
|
||||
kindSubs, hasKindSubs := d.byKind.Load(event.Kind)
|
||||
authorSubs, hasAuthorSubs := d.byAuthor[event.PubKey]
|
||||
kindSubs, hasKindSubs := d.byKind[event.Kind]
|
||||
|
||||
if hasAuthorSubs && hasKindSubs {
|
||||
for _, ssid := range authorSubs.Slice() {
|
||||
sub, _ := d.subscriptions.Load(ssid)
|
||||
|
||||
if kindSubs.Has(ssid) || sub.filter.Kinds == nil {
|
||||
if kindSubs.Has(ssid) {
|
||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||
if !yield(sub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssid := range kindSubs.Slice() {
|
||||
sub, _ := d.subscriptions.Load(ssid)
|
||||
|
||||
if sub.filter.Authors != nil {
|
||||
} else {
|
||||
// matched author but not tags, so this event doesn't qualify for any filter
|
||||
continue
|
||||
}
|
||||
|
||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||
if !yield(sub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if hasAuthorSubs {
|
||||
for _, ssid := range authorSubs.Slice() {
|
||||
@@ -221,21 +177,10 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
||||
}
|
||||
}
|
||||
|
||||
if len(event.Tags) > 0 {
|
||||
for _, ssid := range d.fallbackTags.Slice() {
|
||||
sub, _ := d.subscriptions.Load(ssid)
|
||||
|
||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||
if !yield(sub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssid := range d.fallbackNothing.Slice() {
|
||||
for _, ssid := range d.fallback.Slice() {
|
||||
sub, _ := d.subscriptions.Load(ssid)
|
||||
if filterMatchesTimestampConstraints(sub.filter, event) {
|
||||
|
||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||
if !yield(sub) {
|
||||
return
|
||||
}
|
||||
@@ -245,7 +190,7 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func filterMatchesTimestampConstraints(filter nostr.Filter, event nostr.Event) bool {
|
||||
func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.Event) bool {
|
||||
if filter.Since != 0 && event.CreatedAt < filter.Since {
|
||||
return false
|
||||
}
|
||||
@@ -254,15 +199,6 @@ func filterMatchesTimestampConstraints(filter nostr.Filter, event nostr.Event) b
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.Event) bool {
|
||||
if !filterMatchesTimestampConstraints(filter, event) {
|
||||
return false
|
||||
}
|
||||
|
||||
for f, v := range filter.Tags {
|
||||
if !event.Tags.ContainsAny(f, v) {
|
||||
return false
|
||||
@@ -311,10 +247,6 @@ func (rl *Relay) addListener(
|
||||
cancel: cancel,
|
||||
sid: id,
|
||||
})
|
||||
|
||||
if rl.OnListenerAdded != nil {
|
||||
rl.OnListenerAdded(ws, ssid, id, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,12 +261,7 @@ func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
|
||||
for _, spec := range specs {
|
||||
if spec.sid == id {
|
||||
spec.cancel(ErrSubscriptionClosedByClient)
|
||||
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
||||
|
||||
if rl.OnListenerRemoved != nil {
|
||||
rl.OnListenerRemoved(ws, spec.ssid, id, filter)
|
||||
}
|
||||
|
||||
rl.dispatcher.removeSubscription(spec.ssid)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, spec)
|
||||
@@ -349,11 +276,7 @@ func (rl *Relay) removeClientAndListeners(ws *WebSocket) {
|
||||
if specs, ok := rl.clients[ws]; ok {
|
||||
for _, spec := range specs {
|
||||
// no need to cancel contexts since they inherit from the main connection context
|
||||
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
||||
|
||||
if rl.OnListenerRemoved != nil {
|
||||
rl.OnListenerRemoved(ws, spec.ssid, spec.sid, filter)
|
||||
}
|
||||
rl.dispatcher.removeSubscription(spec.ssid)
|
||||
}
|
||||
}
|
||||
delete(rl.clients, ws)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -124,7 +125,10 @@ func FuzzRandomListenerIdRemoving(f *testing.F) {
|
||||
}
|
||||
require.Equal(t, len(subs)+extra, ssidCount)
|
||||
|
||||
for _, wsidToRemove := range moduloOrder(subs, int(utw+ubs+ualf+ualef)) {
|
||||
rand.Shuffle(len(subs), func(i, j int) {
|
||||
subs[i], subs[j] = subs[j], subs[i]
|
||||
})
|
||||
for _, wsidToRemove := range subs {
|
||||
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||
}
|
||||
|
||||
|
||||
+8
-16
@@ -1,6 +1,7 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -22,18 +23,6 @@ func idFromSeq(seq int, min, max int) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func moduloOrder[T any](items []T, seed int) []T {
|
||||
remaining := append([]T(nil), items...)
|
||||
ordered := make([]T, 0, len(items))
|
||||
for len(remaining) > 0 {
|
||||
idx := seed % len(remaining)
|
||||
ordered = append(ordered, remaining[idx])
|
||||
remaining = append(remaining[:idx], remaining[idx+1:]...)
|
||||
seed++
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func TestListenerSetupAndRemoveOnce(t *testing.T) {
|
||||
rl := NewRelay()
|
||||
|
||||
@@ -332,7 +321,7 @@ func TestRandomListenerClientRemoving(t *testing.T) {
|
||||
ws := websockets[i]
|
||||
w := idFromSeqUpper(i)
|
||||
|
||||
if (i+j)%2 == 0 {
|
||||
if rand.Intn(2) < 1 {
|
||||
l++
|
||||
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
|
||||
}
|
||||
@@ -385,12 +374,12 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
||||
ws := websockets[i]
|
||||
w := idFromSeqUpper(i)
|
||||
|
||||
if (i+j)%2 == 0 {
|
||||
if rand.Intn(2) < 1 {
|
||||
id := w + ":" + idFromSeqLower(j)
|
||||
rl.addListener(ws, id, f, cancel)
|
||||
subs = append(subs, wsid{ws, id})
|
||||
|
||||
if (i+j)%5 == 0 {
|
||||
if rand.Intn(5) < 1 {
|
||||
rl.addListener(ws, id, f, cancel)
|
||||
extra++
|
||||
}
|
||||
@@ -405,7 +394,10 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
||||
}
|
||||
require.Equal(t, len(subs)+extra, ssidCount)
|
||||
|
||||
for _, wsidToRemove := range moduloOrder(subs, 20) {
|
||||
rand.Shuffle(len(subs), func(i, j int) {
|
||||
subs[i], subs[j] = subs[j], subs[i]
|
||||
})
|
||||
for _, wsidToRemove := range subs {
|
||||
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||
}
|
||||
|
||||
|
||||
+5
-109
@@ -2,8 +2,6 @@ package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"iter"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/lib/channelmutex"
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -71,7 +68,7 @@ type Relay struct {
|
||||
// hooks that will be called at various times
|
||||
OnEvent func(ctx context.Context, event nostr.Event) (reject bool, msg string)
|
||||
StoreEvent func(ctx context.Context, event nostr.Event) error
|
||||
ReplaceEvent func(ctx context.Context, event nostr.Event) error
|
||||
ReplaceEvent func(ctx context.Context, event nostr.Event) ([]nostr.Event, error)
|
||||
DeleteEvent func(ctx context.Context, id nostr.ID) error
|
||||
OnEventSaved func(ctx context.Context, event nostr.Event)
|
||||
OnEventDeleted func(ctx context.Context, deleted nostr.Event)
|
||||
@@ -84,8 +81,6 @@ type Relay struct {
|
||||
RejectConnection func(r *http.Request) bool
|
||||
OnConnect func(ctx context.Context)
|
||||
OnDisconnect func(ctx context.Context)
|
||||
OnListenerAdded func(ws *WebSocket, ssid int, id string, filter nostr.Filter)
|
||||
OnListenerRemoved func(ws *WebSocket, ssid int, id string, filter nostr.Filter)
|
||||
OverwriteRelayInformation func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
|
||||
PreventBroadcast func(ws *WebSocket, filter nostr.Filter, event nostr.Event) bool
|
||||
|
||||
@@ -150,9 +145,8 @@ func (rl *Relay) UseEventstore(store eventstore.Store, maxQueryLimit int) {
|
||||
rl.StoreEvent = func(ctx context.Context, event nostr.Event) error {
|
||||
return store.SaveEvent(event)
|
||||
}
|
||||
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) error {
|
||||
_, err := store.ReplaceEvent(event)
|
||||
return err
|
||||
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) ([]nostr.Event, error) {
|
||||
return store.ReplaceEvent(event)
|
||||
}
|
||||
rl.DeleteEvent = func(ctx context.Context, id nostr.ID) error {
|
||||
return store.DeleteEvent(id)
|
||||
@@ -171,8 +165,8 @@ func (rl *Relay) UseEventstore(store eventstore.Store, maxQueryLimit int) {
|
||||
}
|
||||
|
||||
func (rl *Relay) getBaseURL(r *http.Request) string {
|
||||
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
||||
return serviceURL
|
||||
if rl.ServiceURL != "" {
|
||||
return rl.ServiceURL
|
||||
}
|
||||
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
@@ -197,14 +191,6 @@ func (rl *Relay) getBaseURL(r *http.Request) string {
|
||||
return proto + "://" + host + r.URL.Path
|
||||
}
|
||||
|
||||
func (rl *Relay) getServiceURL(r *http.Request) string {
|
||||
if serviceURL, ok := r.Context().Value(serviceURLOverrideKey).(string); ok {
|
||||
return serviceURL
|
||||
}
|
||||
|
||||
return rl.ServiceURL
|
||||
}
|
||||
|
||||
// Stats returns the current number of connected clients and open listeners.
|
||||
func (rl *Relay) Stats() (clients, listeners int) {
|
||||
rl.clientsMutex.Lock()
|
||||
@@ -217,89 +203,6 @@ func (rl *Relay) Stats() (clients, listeners int) {
|
||||
return len(rl.clients), listeners
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
ID string
|
||||
IP string
|
||||
UserAgent string
|
||||
Origin string
|
||||
Authenticated []nostr.PubKey
|
||||
SubscriptionCount int
|
||||
}
|
||||
|
||||
type SubscriptionInfo struct {
|
||||
ID string
|
||||
Filter nostr.Filter
|
||||
}
|
||||
|
||||
type ClientSnapshot struct {
|
||||
ClientInfo
|
||||
Subscriptions []SubscriptionInfo
|
||||
}
|
||||
|
||||
func (rl *Relay) ListClients() []ClientInfo {
|
||||
rl.clientsMutex.Lock()
|
||||
defer rl.clientsMutex.Unlock()
|
||||
|
||||
clients := make([]ClientInfo, 0, len(rl.clients))
|
||||
for ws, specs := range rl.clients {
|
||||
clients = append(clients, ClientInfo{
|
||||
ID: ws.GetID(),
|
||||
IP: GetIPFromRequest(ws.Request),
|
||||
UserAgent: ws.Request.UserAgent(),
|
||||
Origin: ws.Request.Header.Get("Origin"),
|
||||
Authenticated: ws.AuthedPublicKeys,
|
||||
SubscriptionCount: len(specs),
|
||||
})
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
func (rl *Relay) GetClientSnapshot(id string) (ClientSnapshot, bool) {
|
||||
rl.clientsMutex.Lock()
|
||||
defer rl.clientsMutex.Unlock()
|
||||
|
||||
ptrn, err := base64.RawURLEncoding.DecodeString(id)
|
||||
if err != nil {
|
||||
return ClientSnapshot{}, false
|
||||
}
|
||||
ptr := binary.LittleEndian.Uint64(ptrn)
|
||||
|
||||
// DANGEROUS:
|
||||
// don't try to do anything with this `ws` object before we confirm it exists by checking the rl.clients map
|
||||
ws := (*WebSocket)(unsafe.Pointer(uintptr(ptr)))
|
||||
specs, ok := rl.clients[ws]
|
||||
if !ok {
|
||||
return ClientSnapshot{}, false
|
||||
}
|
||||
|
||||
details := ClientSnapshot{
|
||||
ClientInfo: ClientInfo{
|
||||
ID: id,
|
||||
IP: GetIPFromRequest(ws.Request),
|
||||
UserAgent: ws.Request.UserAgent(),
|
||||
Origin: ws.Request.Header.Get("Origin"),
|
||||
Authenticated: ws.AuthedPublicKeys,
|
||||
SubscriptionCount: len(specs),
|
||||
},
|
||||
Subscriptions: make([]SubscriptionInfo, 0, len(specs)),
|
||||
}
|
||||
|
||||
for _, spec := range specs {
|
||||
filter := nostr.Filter{}
|
||||
if sub, ok := rl.dispatcher.subscriptions.Load(spec.ssid); ok {
|
||||
filter = sub.filter
|
||||
}
|
||||
|
||||
details.Subscriptions = append(details.Subscriptions, SubscriptionInfo{
|
||||
ID: spec.sid,
|
||||
Filter: filter,
|
||||
})
|
||||
}
|
||||
|
||||
return details, true
|
||||
}
|
||||
|
||||
func (rl *Relay) Router() *http.ServeMux {
|
||||
return rl.serveMux
|
||||
}
|
||||
@@ -307,10 +210,3 @@ func (rl *Relay) Router() *http.ServeMux {
|
||||
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
||||
rl.serveMux = mux
|
||||
}
|
||||
|
||||
func (rl *Relay) WithServiceURL(serviceURL string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), serviceURLOverrideKey, serviceURL)
|
||||
rl.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package khatru
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,15 +14,13 @@ import (
|
||||
)
|
||||
|
||||
func FuzzReplaceableEvents(f *testing.F) {
|
||||
f.Add(1, 1, uint(2))
|
||||
f.Add(uint(1), uint(2))
|
||||
|
||||
f.Fuzz(func(t *testing.T, seed int, advance int, nevents uint) {
|
||||
f.Fuzz(func(t *testing.T, seed uint, nevents uint) {
|
||||
if nevents == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
state := fuzzState{value: seed, advance: advance}
|
||||
|
||||
relay := NewRelay()
|
||||
store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"}
|
||||
store.Init()
|
||||
@@ -68,10 +67,12 @@ func FuzzReplaceableEvents(f *testing.F) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rnd := rand.New(rand.NewPCG(uint64(seed), 0))
|
||||
|
||||
newest := nostr.Timestamp(0)
|
||||
for range nevents {
|
||||
evt := createEvent(sk1, 0, `{"name":"blblbl"}`, nil)
|
||||
evt.CreatedAt = nostr.Timestamp(state.next(math.MaxUint32))
|
||||
evt.CreatedAt = nostr.Timestamp(rnd.Int64() % math.MaxUint32)
|
||||
evt.Sign(sk1)
|
||||
err = client1.Publish(ctx, evt)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,9 +2,6 @@ package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -12,60 +9,9 @@ import (
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/eventstore/slicestore"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithServiceURL(t *testing.T) {
|
||||
relay := NewRelay()
|
||||
relay.Info.Icon = "icon.png"
|
||||
relay.Info.Banner = "banner.png"
|
||||
relay.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
io.WriteString(w, "fallback")
|
||||
})
|
||||
|
||||
handlerA := relay.WithServiceURL("https://a.example/relay")
|
||||
handlerB := relay.WithServiceURL("https://b.example/relay")
|
||||
|
||||
t.Run("uses override for nip11 base url", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
handler http.Handler
|
||||
expectedBase string
|
||||
}{
|
||||
{name: "first interface", handler: handlerA, expectedBase: "https://a.example/relay"},
|
||||
{name: "second interface", handler: handlerB, expectedBase: "https://b.example/relay"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://internal/relay", nil)
|
||||
req.Header.Set("Accept", "application/nostr+json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
tc.handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var info nip11.RelayInformationDocument
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&info))
|
||||
require.Equal(t, tc.expectedBase+"/icon.png", info.Icon)
|
||||
require.Equal(t, tc.expectedBase+"/banner.png", info.Banner)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses override for relay path matching", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://internal/not-relay", nil)
|
||||
req.Header.Set("Accept", "application/nostr+json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handlerA.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, rr.Code)
|
||||
require.Equal(t, "fallback", rr.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestBasicRelayFunctionality(t *testing.T) {
|
||||
// setup relay with in-memory store
|
||||
relay := NewRelay()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
go test fuzz v1
|
||||
int(-180)
|
||||
int(92)
|
||||
byte('{')
|
||||
byte('\n')
|
||||
@@ -1,5 +0,0 @@
|
||||
go test fuzz v1
|
||||
int(140)
|
||||
int(-52)
|
||||
byte('"')
|
||||
byte('h')
|
||||
@@ -1,4 +1,3 @@
|
||||
go test fuzz v1
|
||||
int(25)
|
||||
int(1)
|
||||
uint(25)
|
||||
uint(223)
|
||||
|
||||
@@ -11,7 +11,6 @@ const (
|
||||
subscriptionIdKey
|
||||
nip86HeaderAuthKey
|
||||
internalCallKey
|
||||
serviceURLOverrideKey
|
||||
)
|
||||
|
||||
func RequestAuth(ctx context.Context) {
|
||||
@@ -74,13 +73,6 @@ func IsAuthed(ctx context.Context, pubkey nostr.PubKey) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ForceSetAuthed modifies the context to insert a custom authed public key.
|
||||
// It can be used in testing or other rare scenarios for making requests as if a given public key
|
||||
// was authenticated when in fact it didn't perform any of the authentication rituals.
|
||||
func ForceSetAuthed(ctx context.Context, pubkey nostr.PubKey) context.Context {
|
||||
return context.WithValue(ctx, nip86HeaderAuthKey, pubkey)
|
||||
}
|
||||
|
||||
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
|
||||
// or expiration request.
|
||||
func IsInternalCall(ctx context.Context) bool {
|
||||
|
||||
@@ -2,12 +2,9 @@ package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fasthttp/websocket"
|
||||
@@ -34,13 +31,6 @@ type WebSocket struct {
|
||||
negentropySessions *xsync.MapOf[string, *NegentropySession]
|
||||
}
|
||||
|
||||
func (ws *WebSocket) GetID() string {
|
||||
ptr := uintptr(unsafe.Pointer(ws))
|
||||
var id [8]byte
|
||||
binary.LittleEndian.PutUint64(id[:], uint64(ptr))
|
||||
return base64.RawURLEncoding.EncodeToString(id[:])
|
||||
}
|
||||
|
||||
func (ws *WebSocket) WriteJSON(any any) error {
|
||||
if ws == nil {
|
||||
return fmt.Errorf("connection doesn't exist")
|
||||
|
||||
@@ -274,10 +274,6 @@ func (kind Kind) Name() string {
|
||||
return "VideoViewEvent"
|
||||
case KindCommunityDefinition:
|
||||
return "CommunityDefinition"
|
||||
case KindNsiteRoot:
|
||||
return "NsiteRoot"
|
||||
case KindNsiteNamed:
|
||||
return "NsiteNamed"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -364,7 +360,6 @@ const (
|
||||
KindGoodWikiAuthorList Kind = 10101
|
||||
KindGoodWikiRelayList Kind = 10102
|
||||
KindNWCWalletInfo Kind = 13194
|
||||
KindNsiteRoot Kind = 15128
|
||||
KindLightningPubRPC Kind = 21000
|
||||
KindClientAuthentication Kind = 22242
|
||||
KindNWCWalletRequest Kind = 23194
|
||||
@@ -399,7 +394,6 @@ const (
|
||||
KindDraftClassifiedListing Kind = 30403
|
||||
KindRepositoryAnnouncement Kind = 30617
|
||||
KindRepositoryState Kind = 30618
|
||||
KindNsiteNamed Kind = 35128
|
||||
KindSimpleGroupMetadata Kind = 39000
|
||||
KindSimpleGroupAdmins Kind = 39001
|
||||
KindSimpleGroupMembers Kind = 39002
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package nip5a
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
)
|
||||
|
||||
func NormalizePath(p string) string {
|
||||
if !strings.HasSuffix(p, ".html") && !strings.HasSuffix(p, "/") {
|
||||
return p
|
||||
}
|
||||
if strings.HasSuffix(p, "/") {
|
||||
return p + "index.html"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func PubKeyFromBase36(value string) (nostr.PubKey, error) {
|
||||
bi, ok := new(big.Int).SetString(value, 36)
|
||||
if !ok {
|
||||
return nostr.ZeroPK, fmt.Errorf("invalid base36 pubkey")
|
||||
}
|
||||
buf := bi.Bytes()
|
||||
if len(buf) > 32 {
|
||||
return nostr.ZeroPK, fmt.Errorf("base36 pubkey too long")
|
||||
}
|
||||
var pk nostr.PubKey
|
||||
copy(pk[32-len(buf):], buf)
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
func PubKeyToBase36(pubkey nostr.PubKey) string {
|
||||
value := new(big.Int).SetBytes(pubkey[:]).Text(36)
|
||||
return strings.Repeat("0", 50-len(value)) + value
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
package nip5a
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
)
|
||||
|
||||
type SiteManifest struct {
|
||||
Event *nostr.Event
|
||||
Pubkey nostr.PubKey
|
||||
Root bool
|
||||
Identifier string
|
||||
Paths map[string][32]byte
|
||||
Servers []string
|
||||
Title string
|
||||
Description string
|
||||
Source string
|
||||
}
|
||||
|
||||
func ParseSiteManifest(event *nostr.Event) (*SiteManifest, error) {
|
||||
sm := &SiteManifest{Event: event}
|
||||
|
||||
switch event.Kind {
|
||||
case nostr.KindNsiteRoot:
|
||||
sm.Root = true
|
||||
case nostr.KindNsiteNamed:
|
||||
sm.Root = false
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "d" {
|
||||
sm.Identifier = tag[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if sm.Identifier == "" {
|
||||
return nil, fmt.Errorf("named site manifest missing d tag")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid site manifest kind: %d", event.Kind)
|
||||
}
|
||||
|
||||
sm.Pubkey = event.PubKey
|
||||
sm.Paths = make(map[string][32]byte, len(event.Tags))
|
||||
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) < 2 {
|
||||
continue
|
||||
}
|
||||
switch tag[0] {
|
||||
case "path":
|
||||
var hash [32]byte
|
||||
if len(tag[2]) != 64 {
|
||||
return nil, fmt.Errorf("invalid hash '%s' for path '%s'", tag[2], tag[1])
|
||||
}
|
||||
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(tag[2]), 64)); err != nil {
|
||||
return nil, fmt.Errorf("invalid hash '%s' for path '%s'", tag[2], tag[1])
|
||||
}
|
||||
sm.Paths[NormalizePath(tag[1])] = hash
|
||||
case "server":
|
||||
sm.Servers = append(sm.Servers, tag[1])
|
||||
case "title":
|
||||
sm.Title = tag[1]
|
||||
case "description":
|
||||
sm.Description = tag[1]
|
||||
case "source":
|
||||
sm.Source = tag[1]
|
||||
}
|
||||
}
|
||||
|
||||
if len(sm.Paths) == 0 {
|
||||
return sm, fmt.Errorf("nsite has zero paths listed")
|
||||
}
|
||||
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
func (sm SiteManifest) ToEvent() nostr.Event {
|
||||
event := nostr.Event{
|
||||
PubKey: sm.Pubkey,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{},
|
||||
}
|
||||
|
||||
if sm.Root {
|
||||
event.Kind = nostr.KindNsiteRoot
|
||||
} else {
|
||||
event.Kind = nostr.KindNsiteNamed
|
||||
event.Tags = append(event.Tags, nostr.Tag{"d", sm.Identifier})
|
||||
}
|
||||
|
||||
for path, hash := range sm.Paths {
|
||||
event.Tags = append(event.Tags, nostr.Tag{"path", NormalizePath(path), hex.EncodeToString(hash[:])})
|
||||
}
|
||||
for _, s := range sm.Servers {
|
||||
event.Tags = append(event.Tags, nostr.Tag{"server", s})
|
||||
}
|
||||
if sm.Title != "" {
|
||||
event.Tags = append(event.Tags, nostr.Tag{"title", sm.Title})
|
||||
}
|
||||
if sm.Description != "" {
|
||||
event.Tags = append(event.Tags, nostr.Tag{"description", sm.Description})
|
||||
}
|
||||
if sm.Source != "" {
|
||||
event.Tags = append(event.Tags, nostr.Tag{"source", sm.Source})
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func (sm *SiteManifest) GetHashForPath(path string) ([32]byte, bool) {
|
||||
path = NormalizePath(path)
|
||||
hash, ok := sm.Paths[path]
|
||||
return hash, ok
|
||||
}
|
||||
|
||||
func DecodeSiteURL(label string) (pubkey nostr.PubKey, identifier string, isRoot bool, err error) {
|
||||
label, _, _ = strings.Cut(label, ".")
|
||||
|
||||
if strings.HasPrefix(label, "npub1") {
|
||||
_, value, err := nip19.Decode(label)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, "", false, err
|
||||
}
|
||||
return value.(nostr.PubKey), "", true, nil
|
||||
}
|
||||
|
||||
if len(label) < 51 || len(label) > 63 || strings.HasSuffix(label, "-") {
|
||||
return nostr.ZeroPK, "", false, fmt.Errorf("invalid site label format")
|
||||
}
|
||||
|
||||
pubkeyB36 := label[:50]
|
||||
dTag := label[50:]
|
||||
if !regexp.MustCompile(`^[a-z0-9-]{1,13}$`).MatchString(dTag) {
|
||||
return nostr.ZeroPK, "", false, fmt.Errorf("invalid dtag format")
|
||||
}
|
||||
|
||||
pk, err := PubKeyFromBase36(pubkeyB36)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, "", false, err
|
||||
}
|
||||
|
||||
return pk, dTag, false, nil
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package nip5a
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSiteManifest(t *testing.T) {
|
||||
pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5")
|
||||
|
||||
t.Run("root site", func(t *testing.T) {
|
||||
event := &nostr.Event{
|
||||
Kind: nostr.KindNsiteRoot,
|
||||
PubKey: pubkey,
|
||||
Tags: nostr.Tags{
|
||||
{"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"},
|
||||
{"path", "/about.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"},
|
||||
{"path", "/favicon.ico", "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"},
|
||||
{"server", "https://blossom.example.com"},
|
||||
{"title", "My Nostr Site"},
|
||||
{"description", "A static website hosted on Nostr"},
|
||||
{"source", "https://github.com/example/my-nostr-site"},
|
||||
},
|
||||
}
|
||||
|
||||
sm, err := ParseSiteManifest(event)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sm.Root)
|
||||
assert.Equal(t, pubkey, sm.Pubkey)
|
||||
assert.Equal(t, "My Nostr Site", sm.Title)
|
||||
assert.Equal(t, "A static website hosted on Nostr", sm.Description)
|
||||
assert.Equal(t, "https://github.com/example/my-nostr-site", sm.Source)
|
||||
assert.Len(t, sm.Paths, 3)
|
||||
assert.Len(t, sm.Servers, 1)
|
||||
assert.Equal(t, "https://blossom.example.com", sm.Servers[0])
|
||||
})
|
||||
|
||||
t.Run("named site", func(t *testing.T) {
|
||||
event := &nostr.Event{
|
||||
Kind: nostr.KindNsiteNamed,
|
||||
PubKey: pubkey,
|
||||
Tags: nostr.Tags{
|
||||
{"d", "blog"},
|
||||
{"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"},
|
||||
{"path", "/post.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"},
|
||||
{"server", "https://blossom.example.com"},
|
||||
{"title", "My Blog"},
|
||||
{"description", "A blog hosted on Nostr"},
|
||||
{"source", "https://github.com/example/my-nostr-blog"},
|
||||
},
|
||||
}
|
||||
|
||||
sm, err := ParseSiteManifest(event)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sm.Root)
|
||||
assert.Equal(t, "blog", sm.Identifier)
|
||||
assert.Equal(t, pubkey, sm.Pubkey)
|
||||
assert.Equal(t, "My Blog", sm.Title)
|
||||
})
|
||||
|
||||
t.Run("missing d tag on named site", func(t *testing.T) {
|
||||
event := &nostr.Event{
|
||||
Kind: nostr.KindNsiteNamed,
|
||||
PubKey: pubkey,
|
||||
Tags: nostr.Tags{
|
||||
{"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ParseSiteManifest(event)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing d tag")
|
||||
})
|
||||
|
||||
t.Run("invalid kind", func(t *testing.T) {
|
||||
event := &nostr.Event{
|
||||
Kind: 1,
|
||||
PubKey: pubkey,
|
||||
Tags: nostr.Tags{},
|
||||
}
|
||||
|
||||
_, err := ParseSiteManifest(event)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid site manifest kind")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHashForPath(t *testing.T) {
|
||||
pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5")
|
||||
event := &nostr.Event{
|
||||
Kind: nostr.KindNsiteRoot,
|
||||
PubKey: pubkey,
|
||||
Tags: nostr.Tags{
|
||||
{"path", "/index.html", "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"},
|
||||
{"path", "/about.html", "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"},
|
||||
},
|
||||
}
|
||||
|
||||
sm, err := ParseSiteManifest(event)
|
||||
require.NoError(t, err)
|
||||
|
||||
hash, ok := sm.GetHashForPath("/index.html")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99", hex.EncodeToString(hash[:]))
|
||||
|
||||
_, ok = sm.GetHashForPath("/nonexistent.html")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestNormalizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/index.html", "/index.html"},
|
||||
{"/about.html", "/about.html"},
|
||||
{"/blog/", "/blog/index.html"},
|
||||
{"/", "/index.html"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := NormalizePath(test.input)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPubKeyBase36(t *testing.T) {
|
||||
pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5")
|
||||
|
||||
b36 := PubKeyToBase36(pubkey)
|
||||
assert.Len(t, b36, 50)
|
||||
|
||||
decoded, err := PubKeyFromBase36(b36)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pubkey, decoded)
|
||||
}
|
||||
|
||||
func TestDecodeSiteURL(t *testing.T) {
|
||||
pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5")
|
||||
|
||||
t.Run("npub root site", func(t *testing.T) {
|
||||
decodedPubkey, identifier, isRoot, err := DecodeSiteURL("npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isRoot)
|
||||
assert.Equal(t, "", identifier)
|
||||
assert.Equal(t, decodedPubkey, pubkey)
|
||||
})
|
||||
|
||||
t.Run("named site", func(t *testing.T) {
|
||||
b36 := PubKeyToBase36(pubkey)
|
||||
label := b36 + "blog"
|
||||
|
||||
decodedPubkey, identifier, isRoot, err := DecodeSiteURL(label)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isRoot)
|
||||
assert.Equal(t, "blog", identifier)
|
||||
assert.Equal(t, decodedPubkey, pubkey)
|
||||
})
|
||||
|
||||
t.Run("strips domain suffix", func(t *testing.T) {
|
||||
b36 := PubKeyToBase36(pubkey)
|
||||
label := b36 + "blog.nsite-host.com"
|
||||
|
||||
_, identifier, _, err := DecodeSiteURL(label)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blog", identifier)
|
||||
})
|
||||
|
||||
t.Run("invalid dtag format", func(t *testing.T) {
|
||||
b36 := PubKeyToBase36(pubkey)
|
||||
label := b36 + "Blog"
|
||||
|
||||
_, _, _, err := DecodeSiteURL(label)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid dtag format")
|
||||
})
|
||||
|
||||
t.Run("label too short", func(t *testing.T) {
|
||||
_, _, _, err := DecodeSiteURL("npub1")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("ends with dash", func(t *testing.T) {
|
||||
b36 := PubKeyToBase36(pubkey)
|
||||
label := b36 + "blog-"
|
||||
|
||||
_, _, _, err := DecodeSiteURL(label)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid site label format")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteManifestToEvent(t *testing.T) {
|
||||
pubkey := nostr.MustPubKeyFromHex("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5")
|
||||
|
||||
sm := &SiteManifest{
|
||||
Root: true,
|
||||
Pubkey: pubkey,
|
||||
Identifier: "",
|
||||
Paths: map[string][32]byte{
|
||||
"/index.html": mustHash("186ea5fd14e88fd1ac49351759e7ab906fa94892002b60bf7f5a428f28ca1c99"),
|
||||
},
|
||||
Servers: []string{"https://blossom.example.com"},
|
||||
Title: "Test Site",
|
||||
Description: "A test site",
|
||||
Source: "https://github.com/example/test",
|
||||
}
|
||||
|
||||
event := sm.ToEvent()
|
||||
assert.Equal(t, nostr.KindNsiteRoot, event.Kind)
|
||||
assert.Equal(t, pubkey, event.PubKey)
|
||||
|
||||
sm.Root = false
|
||||
sm.Identifier = "blog"
|
||||
event = sm.ToEvent()
|
||||
assert.Equal(t, nostr.KindNsiteNamed, event.Kind)
|
||||
found := false
|
||||
for _, tag := range event.Tags {
|
||||
if tag[0] == "d" && tag[1] == "blog" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func mustHash(s string) [32]byte {
|
||||
var h [32]byte
|
||||
b, _ := hex.DecodeString(s)
|
||||
copy(h[:], b)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package blossom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,17 +11,19 @@ import (
|
||||
)
|
||||
|
||||
// Download downloads a file from the media server by its hash
|
||||
func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) {
|
||||
hhash := hex.EncodeToString(hash[:])
|
||||
func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) {
|
||||
if !nostr.IsValid32ByteHex(hash) {
|
||||
return nil, fmt.Errorf("%s is not a valid 32-byte hex string", hash)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"x", hash})
|
||||
})
|
||||
req.Header.Add("Authorization", authHeader)
|
||||
|
||||
@@ -40,17 +41,19 @@ func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
// DownloadToFile downloads a file from the media server and saves it to the specified path
|
||||
func (c *Client) DownloadToFile(ctx context.Context, hash [32]byte, filePath string) error {
|
||||
hhash := hex.EncodeToString(hash[:])
|
||||
func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error {
|
||||
if !nostr.IsValid32ByteHex(hash) {
|
||||
return fmt.Errorf("%s is not a valid 32-byte hex string", hash)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"x", hash})
|
||||
})
|
||||
req.Header.Add("Authorization", authHeader)
|
||||
|
||||
|
||||
@@ -313,9 +313,7 @@ func easyjson33014d6eEncodeFiatjafComNostr2(out *jwriter.Writer, in EntityPointe
|
||||
out.RawString(prefix)
|
||||
out.Uint(uint(in.Kind))
|
||||
}
|
||||
if in.Identifier == "" && in.Kind.IsReplaceable() {
|
||||
// this is expected, no identifiers in replaceable events, so don't print
|
||||
// but we do print in case there is an identifier, incorrectly, to assist debug
|
||||
{
|
||||
const prefix string = ",\"identifier\":"
|
||||
out.RawString(prefix)
|
||||
out.String(in.Identifier)
|
||||
|
||||
@@ -24,26 +24,13 @@ type Pool struct {
|
||||
Relays *xsync.MapOf[string, *Relay]
|
||||
Context context.Context
|
||||
|
||||
cancel context.CancelCauseFunc
|
||||
authRequiredHandler func(context.Context, *Event) error
|
||||
cancel context.CancelCauseFunc
|
||||
|
||||
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
|
||||
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
|
||||
// with the "auth-required:" prefix, only once for each relay
|
||||
AuthRequiredHandler func(context.Context, *Event) error
|
||||
|
||||
// EventMiddleware is a function that will be called with all events received.
|
||||
EventMiddleware func(RelayEvent)
|
||||
|
||||
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
|
||||
EventMiddleware func(RelayEvent)
|
||||
DuplicateMiddleware func(relay string, id ID)
|
||||
|
||||
// AuthorKindQueryMiddleware is a function that will be called with every combination of
|
||||
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
|
||||
// contains a pubkey and a kind).
|
||||
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
||||
|
||||
// RelayOptions are any options that should be passed to Relays instantiated by this pool
|
||||
RelayOptions RelayOptions
|
||||
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
||||
RelayOptions RelayOptions
|
||||
|
||||
// custom things not often used
|
||||
penaltyBox *xsync.MapOf[string, [2]float64]
|
||||
@@ -73,6 +60,31 @@ func NewPool() *Pool {
|
||||
}
|
||||
}
|
||||
|
||||
type PoolOptions struct {
|
||||
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
|
||||
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
|
||||
// with the "auth-required:" prefix, only once for each relay
|
||||
AuthRequiredHandler func(context.Context, *Event) error
|
||||
|
||||
// PenaltyBox just sets the penalty box mechanism so relays that fail to connect
|
||||
// or that disconnect will be ignored for a while and we won't attempt to connect again.
|
||||
PenaltyBox bool
|
||||
|
||||
// EventMiddleware is a function that will be called with all events received.
|
||||
EventMiddleware func(RelayEvent)
|
||||
|
||||
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
|
||||
DuplicateMiddleware func(relay string, id ID)
|
||||
|
||||
// AuthorKindQueryMiddleware is a function that will be called with every combination of
|
||||
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
|
||||
// contains a pubkey and a kind).
|
||||
AuthorKindQueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
||||
|
||||
// RelayOptions are any options that should be passed to Relays instantiated by this pool
|
||||
RelayOptions RelayOptions
|
||||
}
|
||||
|
||||
func (pool *Pool) StartPenaltyBox() {
|
||||
pool.penaltyBox = xsync.NewMapOf[string, [2]float64]()
|
||||
|
||||
@@ -195,9 +207,9 @@ func (pool *Pool) PublishMany(ctx context.Context, urls []string, evt Event) cha
|
||||
if err := relay.Publish(ctx, evt); err == nil {
|
||||
// success with no auth required
|
||||
ch <- PublishResult{nil, url, relay}
|
||||
} else if strings.HasPrefix(err.Error(), "msg: auth-required:") && pool.AuthRequiredHandler != nil {
|
||||
} else if strings.HasPrefix(err.Error(), "msg: auth-required:") && pool.authRequiredHandler != nil {
|
||||
// try to authenticate if we can
|
||||
if authErr := relay.Auth(ctx, pool.AuthRequiredHandler); authErr == nil {
|
||||
if authErr := relay.Auth(ctx, pool.authRequiredHandler); authErr == nil {
|
||||
if err := relay.Publish(ctx, evt); err == nil {
|
||||
// success after auth
|
||||
ch <- PublishResult{nil, url, relay}
|
||||
@@ -382,9 +394,9 @@ func (pool *Pool) FetchManyReplaceable(
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return
|
||||
case reason := <-sub.ClosedReason:
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
|
||||
// relay is requesting auth. if we can we will perform auth and try again
|
||||
err := relay.Auth(ctx, pool.AuthRequiredHandler)
|
||||
err := relay.Auth(ctx, pool.authRequiredHandler)
|
||||
if err == nil {
|
||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||
goto subscribe
|
||||
@@ -558,9 +570,9 @@ func (pool *Pool) subMany(
|
||||
}
|
||||
}
|
||||
case reason := <-sub.ClosedReason:
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
|
||||
// relay is requesting auth. if we can we will perform auth and try again
|
||||
err := relay.Auth(ctx, pool.AuthRequiredHandler)
|
||||
err := relay.Auth(ctx, pool.authRequiredHandler)
|
||||
if err == nil {
|
||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||
if closedChan != nil {
|
||||
@@ -665,9 +677,9 @@ func (pool *Pool) subManyEose(
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return
|
||||
case reason := <-sub.ClosedReason:
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
|
||||
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
|
||||
// relay is requesting auth. if we can we will perform auth and try again
|
||||
err := relay.Auth(ctx, pool.AuthRequiredHandler)
|
||||
err := relay.Auth(ctx, pool.authRequiredHandler)
|
||||
if err == nil {
|
||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||
if closedChan != nil {
|
||||
|
||||
@@ -93,7 +93,6 @@ func NewRelay(ctx context.Context, url string, opts RelayOptions) *Relay {
|
||||
noticeHandler: opts.NoticeHandler,
|
||||
authHandler: opts.AuthHandler,
|
||||
closed: &atomic.Bool{},
|
||||
AssumeValid: opts.AssumeValid,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -148,9 +147,6 @@ type RelayOptions struct {
|
||||
|
||||
// RequestHeader sets the HTTP request header of the websocket preflight request
|
||||
RequestHeader http.Header
|
||||
|
||||
// AssumeValid disables signature verification for events received from this relay
|
||||
AssumeValid bool
|
||||
}
|
||||
|
||||
// String just returns the relay URL.
|
||||
|
||||
@@ -246,6 +246,10 @@ func (rte RequiredTagError) Error() string {
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateEvent(evt nostr.Event) error {
|
||||
if !isTrimmed(evt.Content) {
|
||||
return ContentError{ErrDanglingSpace}
|
||||
}
|
||||
|
||||
if sch, ok := v.Schema.Kinds[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
|
||||
if validator, ok := v.TypeValidators[sch.Content.Type]; ok {
|
||||
if err := validator(evt.Content, &sch.Content); err != nil {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
cache_memory "fiatjaf.com/nostr/sdk/cache/memory"
|
||||
)
|
||||
|
||||
type BlossomURL string
|
||||
|
||||
func (r BlossomURL) Value() string { return string(r) }
|
||||
|
||||
func (sys *System) FetchBlossomServerList(ctx context.Context, pubkey nostr.PubKey) GenericList[string, BlossomURL] {
|
||||
sys.blossomServerListCacheOnce.Do(func() {
|
||||
if sys.BlossomServerListCache == nil {
|
||||
sys.BlossomServerListCache = cache_memory.New[GenericList[string, BlossomURL]](1000)
|
||||
}
|
||||
})
|
||||
|
||||
ml, _ := fetchGenericList(sys, ctx, pubkey, 10101, kind_10101, func(t nostr.Tag) (BlossomURL, bool) {
|
||||
if len(t) < 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
nm, err := nostr.NormalizeHTTPURL(t[1])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return BlossomURL(nm), true
|
||||
}, sys.BlossomServerListCache)
|
||||
return ml
|
||||
}
|
||||
@@ -61,8 +61,6 @@ type System struct {
|
||||
MediaFollowListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||
goodWikiAuthorListCacheOnce sync.Once
|
||||
GoodWikiAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||
blossomServerListCacheOnce sync.Once
|
||||
BlossomServerListCache cache.Cache32[GenericList[string, BlossomURL]]
|
||||
gitAuthorListCacheOnce sync.Once
|
||||
GitAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||
relaySetsCacheOnce sync.Once
|
||||
|
||||
+35
-13
@@ -19,17 +19,18 @@ type wotCall struct {
|
||||
id uint64 // basically the pubkey we're targeting here
|
||||
mutex sync.Mutex
|
||||
resultbacks []chan WotXorFilter // all callers waiting for results
|
||||
errorbacks []chan error // all callers waiting for errors
|
||||
done chan struct{} // this is closed when this call is fully resolved and deleted
|
||||
}
|
||||
|
||||
const wotCallsSize = 16
|
||||
const wotCallsSize = 8
|
||||
|
||||
var (
|
||||
wotCallsMutex sync.Mutex
|
||||
wotCallsInPlace [wotCallsSize]*wotCall
|
||||
)
|
||||
|
||||
func (sys *System) LoadWoTFilter(ctx context.Context, pubkey nostr.PubKey) WotXorFilter {
|
||||
func (sys *System) LoadWoTFilter(ctx context.Context, pubkey nostr.PubKey) (WotXorFilter, error) {
|
||||
id := PubKeyToShid(pubkey)
|
||||
pos := int(id % wotCallsSize)
|
||||
|
||||
@@ -41,6 +42,7 @@ start:
|
||||
wc = &wotCall{
|
||||
id: id,
|
||||
resultbacks: make([]chan WotXorFilter, 0),
|
||||
errorbacks: make([]chan error, 0),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
wotCallsInPlace[pos] = wc
|
||||
@@ -52,13 +54,17 @@ start:
|
||||
|
||||
wc.mutex.Lock()
|
||||
if wc.id == id {
|
||||
// there is already a call for this exact pubkey ongoing, so we just wait and copy the results
|
||||
// there is already a call for this exact pubkey ongoing, so we just wait
|
||||
resch := make(chan WotXorFilter)
|
||||
errch := make(chan error)
|
||||
wc.resultbacks = append(wc.resultbacks, resch)
|
||||
wc.errorbacks = append(wc.errorbacks, errch)
|
||||
wc.mutex.Unlock()
|
||||
select {
|
||||
case res := <-resch:
|
||||
return res
|
||||
return res, nil
|
||||
case err := <-errch:
|
||||
return WotXorFilter{}, err
|
||||
}
|
||||
} else {
|
||||
wc.mutex.Unlock()
|
||||
@@ -70,11 +76,18 @@ start:
|
||||
|
||||
actualcall:
|
||||
var res WotXorFilter
|
||||
m := sys.loadWoT(ctx, pubkey)
|
||||
res = makeWoTFilter(m)
|
||||
wc.mutex.Lock()
|
||||
for _, ch := range wc.resultbacks {
|
||||
ch <- res
|
||||
m, err := sys.loadWoT(ctx, pubkey)
|
||||
if err != nil {
|
||||
wc.mutex.Lock()
|
||||
for _, ch := range wc.errorbacks {
|
||||
ch <- err
|
||||
}
|
||||
} else {
|
||||
res = makeWoTFilter(m)
|
||||
wc.mutex.Lock()
|
||||
for _, ch := range wc.resultbacks {
|
||||
ch <- res
|
||||
}
|
||||
}
|
||||
|
||||
wotCallsMutex.Lock()
|
||||
@@ -83,17 +96,23 @@ actualcall:
|
||||
close(wc.done)
|
||||
wotCallsMutex.Unlock()
|
||||
|
||||
return res
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) chan nostr.PubKey {
|
||||
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) (chan nostr.PubKey, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(45)
|
||||
|
||||
res := make(chan nostr.PubKey)
|
||||
|
||||
// process follow lists
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
for _, f := range sys.FetchFollowList(ctx, pubkey).Items {
|
||||
wg.Add(1)
|
||||
|
||||
g.Go(func() error {
|
||||
res <- f.Pubkey
|
||||
|
||||
@@ -104,17 +123,20 @@ func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) chan nostr.
|
||||
for _, f2 := range ff {
|
||||
res <- f2.Pubkey
|
||||
}
|
||||
wg.Done()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
g.Wait()
|
||||
wg.Wait()
|
||||
close(res)
|
||||
}()
|
||||
|
||||
return res
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
|
||||
|
||||
+7
-4
@@ -3,6 +3,8 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
@@ -32,8 +34,8 @@ func (evt Event) VerifySignature() bool {
|
||||
sig := schnorr.NewSignature(&r, &s)
|
||||
|
||||
// check signature
|
||||
evt.SetID()
|
||||
return sig.Verify(evt.ID[:], pubkey)
|
||||
hash := sha256.Sum256(evt.Serialize())
|
||||
return sig.Verify(hash[:], pubkey)
|
||||
}
|
||||
|
||||
// Sign signs an event with a given privateKey.
|
||||
@@ -50,12 +52,13 @@ func (evt *Event) Sign(secretKey [32]byte) error {
|
||||
pkBytes := pk.SerializeCompressed()[1:]
|
||||
evt.PubKey = PubKey(pkBytes)
|
||||
|
||||
evt.SetID()
|
||||
sig, err := schnorr.Sign(sk, evt.ID[:], schnorr.FastSign())
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
sig, err := schnorr.Sign(sk, h[:], schnorr.FastSign())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt.ID = h
|
||||
sigb := sig.Serialize()
|
||||
evt.Sig = [64]byte(sigb)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import "C"
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"unsafe"
|
||||
|
||||
@@ -32,14 +33,14 @@ import (
|
||||
)
|
||||
|
||||
func (evt Event) VerifySignature() bool {
|
||||
evt.SetID()
|
||||
msg := sha256.Sum256(evt.Serialize())
|
||||
|
||||
var xonly C.secp256k1_xonly_pubkey
|
||||
if C.secp256k1_xonly_pubkey_parse(globalSecp256k1Context, &xonly, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0]))) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), 32, &xonly)
|
||||
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&msg[0])), 32, &xonly)
|
||||
return res == 1
|
||||
}
|
||||
|
||||
@@ -58,14 +59,16 @@ func (evt *Event) Sign(secretKey [32]byte, signOpts ...schnorr.SignOption) error
|
||||
C.secp256k1_keypair_xonly_pub(globalSecp256k1Context, &xonly, nil, &keypair)
|
||||
C.secp256k1_xonly_pubkey_serialize(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0])), &xonly)
|
||||
|
||||
evt.SetID()
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
|
||||
var random [32]byte
|
||||
rand.Read(random[:])
|
||||
if C.secp256k1_schnorrsig_sign32(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
|
||||
if C.secp256k1_schnorrsig_sign32(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&h[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
|
||||
return errors.New("failed to sign message")
|
||||
}
|
||||
|
||||
evt.ID = h
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -124,19 +124,3 @@ func (tags Tags) ContainsAny(tagName string, values []string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (tags Tags) Eq(other Tags) bool {
|
||||
if len(tags) != len(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, tag := range tags {
|
||||
otherTag := other[i]
|
||||
|
||||
if !slices.Equal(tag, otherTag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
Vendored
-500
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user