Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4daeb8737c | |||
| 7ab69cbc60 |
@@ -2,9 +2,7 @@ package nostr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"hash"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/mailru/easyjson"
|
"github.com/mailru/easyjson"
|
||||||
"github.com/templexxx/xhex"
|
"github.com/templexxx/xhex"
|
||||||
@@ -28,17 +26,10 @@ func (evt Event) String() string {
|
|||||||
|
|
||||||
// GetID serializes and returns the event ID as a string.
|
// GetID serializes and returns the event ID as a string.
|
||||||
func (evt Event) GetID() ID {
|
func (evt Event) GetID() ID {
|
||||||
var id ID
|
return sha256.Sum256(evt.Serialize())
|
||||||
evt.serializedHash(&id)
|
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetID calculates and sets the id to the event in a single operation.
|
// CheckID checks if the implied ID matches the given ID more efficiently.
|
||||||
func (evt *Event) SetID() {
|
|
||||||
evt.serializedHash(&evt.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckID checks if the implied ID matches the currently assigned ID.
|
|
||||||
func (evt Event) CheckID() bool {
|
func (evt Event) CheckID() bool {
|
||||||
return evt.GetID() == evt.ID
|
return evt.GetID() == evt.ID
|
||||||
}
|
}
|
||||||
@@ -47,56 +38,17 @@ func (evt Event) CheckID() bool {
|
|||||||
func (evt Event) Serialize() []byte {
|
func (evt Event) Serialize() []byte {
|
||||||
// the serialization process is just putting everything into a JSON array
|
// the serialization process is just putting everything into a JSON array
|
||||||
// so the order is kept. See NIP-01
|
// so the order is kept. See NIP-01
|
||||||
dst := make([]byte, 0, 100+len(evt.Content)+len(evt.Tags)*80)
|
dst := make([]byte, 4+64, 100+len(evt.Content)+len(evt.Tags)*80)
|
||||||
return evt.appendSerialized(dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
var escTable [256]bool
|
// the header portion is easy to serialize
|
||||||
|
// [0,"pubkey",created_at,kind,[
|
||||||
// pre-built escape sequences; index by the offending byte.
|
copy(dst, `[0,"`)
|
||||||
var escSeq [256][2]byte
|
xhex.Encode(dst[4:4+64], evt.PubKey[:]) // there will always be such capacity
|
||||||
|
|
||||||
// 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[:])
|
|
||||||
dst = append(dst, `",`...)
|
dst = append(dst, `",`...)
|
||||||
dst = strconv.AppendInt(dst, int64(evt.CreatedAt), 10)
|
dst = append(dst, strconv.FormatInt(int64(evt.CreatedAt), 10)...)
|
||||||
dst = append(dst, ',')
|
dst = append(dst, `,`...)
|
||||||
dst = strconv.AppendUint(dst, uint64(evt.Kind), 10)
|
dst = append(dst, strconv.FormatUint(uint64(evt.Kind), 10)...)
|
||||||
dst = append(dst, ',')
|
dst = append(dst, `,`...)
|
||||||
|
|
||||||
// tags
|
// tags
|
||||||
dst = append(dst, '[')
|
dst = append(dst, '[')
|
||||||
@@ -110,167 +62,15 @@ func (evt Event) appendSerialized(dst []byte) []byte {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
dst = append(dst, ',')
|
dst = append(dst, ',')
|
||||||
}
|
}
|
||||||
dst = appendJSONString(dst, s)
|
dst = escapeString(dst, s)
|
||||||
}
|
}
|
||||||
dst = append(dst, ']')
|
dst = append(dst, ']')
|
||||||
}
|
}
|
||||||
dst = append(dst, "],"...)
|
dst = append(dst, "],"...)
|
||||||
|
|
||||||
// content needs to be escaped in general as it is user generated.
|
// 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, ']')
|
dst = append(dst, ']')
|
||||||
|
|
||||||
return 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
|
package nostr
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -106,49 +102,23 @@ func TestIDCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkEventVerifySignatureJSONL(b *testing.B) {
|
func BenchmarkIDCheck(b *testing.B) {
|
||||||
events := loadBenchmarkEvents(b)
|
evt := Event{
|
||||||
b.ReportAllocs()
|
CreatedAt: Timestamp(rand.Int64N(9999999)),
|
||||||
b.ResetTimer()
|
Content: fmt.Sprintf("hello"),
|
||||||
|
Tags: Tags{},
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
for _, evt := range events {
|
|
||||||
if !evt.VerifySignature() {
|
|
||||||
b.Fatal("signature verification failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
evt.Sign(Generate())
|
||||||
|
|
||||||
func loadBenchmarkEvents(b *testing.B) []Event {
|
b.Run("naïve", func(b *testing.B) {
|
||||||
b.Helper()
|
for b.Loop() {
|
||||||
|
_ = evt.GetID() == evt.ID
|
||||||
f, err := os.Open("testdata/events.jsonl")
|
}
|
||||||
require.NoError(b, err)
|
})
|
||||||
b.Cleanup(func() { _ = f.Close() })
|
|
||||||
|
b.Run("big brain", func(b *testing.B) {
|
||||||
r := bufio.NewReader(f)
|
for b.Loop() {
|
||||||
events := make([]Event, 0, 1024)
|
_ = evt.CheckID()
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-33
@@ -202,12 +202,9 @@ func (b *BleveBackend) Init() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.index = index
|
b.index = index
|
||||||
|
b.detector = lingua.NewLanguageDetectorBuilder().
|
||||||
if len(b.Languages) >= 2 {
|
FromLanguages(b.Languages...).
|
||||||
b.detector = lingua.NewLanguageDetectorBuilder().
|
Build()
|
||||||
FromLanguages(b.Languages...).
|
|
||||||
Build()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -251,25 +248,25 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
|
|||||||
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
|
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
|
||||||
references = append(references, pm.NIP05)
|
references = append(references, pm.NIP05)
|
||||||
}
|
}
|
||||||
}
|
case 9802:
|
||||||
|
for _, tag := range evt.Tags {
|
||||||
for _, tag := range evt.Tags {
|
if len(tag) < 2 {
|
||||||
if len(tag) < 2 {
|
continue
|
||||||
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 "a":
|
switch tag[0] {
|
||||||
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
|
case "comment":
|
||||||
references = append(references, ptr.AsTagReference())
|
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()
|
indexableContent := content.String()
|
||||||
|
lang, ok := b.detector.DetectLanguageOf(indexableContent)
|
||||||
var lang lingua.Language
|
if !ok {
|
||||||
if len(b.Languages) == 1 {
|
lang = lingua.English
|
||||||
lang = b.Languages[0]
|
|
||||||
} else {
|
|
||||||
var ok bool
|
|
||||||
lang, ok = b.detector.DetectLanguageOf(indexableContent)
|
|
||||||
if !ok {
|
|
||||||
lang = lingua.English
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var analyzerLangCode string
|
var analyzerLangCode string
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ func (il *IndexingLayer) ComputeStats(opts StatsOptions) (EventStats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := il.lmdbEnv.View(func(txn *lmdb.Txn) error {
|
err := il.lmdbEnv.View(func(txn *lmdb.Txn) error {
|
||||||
txn.RawRead = true
|
|
||||||
|
|
||||||
cursor, err := txn.OpenCursor(il.indexPubkeyKind)
|
cursor, err := txn.OpenCursor(il.indexPubkeyKind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/dgraph-io/ristretto/v2 v2.3.0
|
||||||
github.com/go-git/go-git/v5 v5.16.3
|
github.com/go-git/go-git/v5 v5.16.3
|
||||||
github.com/pemistahl/lingua-go v1.4.0
|
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.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
|
||||||
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
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 h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
|
||||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
|
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=
|
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
|
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 {
|
func subIdToSerial(subId string) int64 {
|
||||||
n := strings.Index(subId, ":")
|
n := strings.Index(subId, ":")
|
||||||
if n < 0 || n > len(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 {
|
} else {
|
||||||
// otherwise it's a replaceable
|
// otherwise it's a replaceable
|
||||||
if nil != rl.ReplaceEvent {
|
if nil != rl.ReplaceEvent {
|
||||||
if err := rl.ReplaceEvent(ctx, evt); err != nil {
|
if _, err := rl.ReplaceEvent(ctx, evt); err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case eventstore.ErrDupEvent:
|
case eventstore.ErrDupEvent:
|
||||||
return true, nil
|
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
|
relayPathMatches := true
|
||||||
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
if rl.ServiceURL != "" {
|
||||||
p, err := url.Parse(serviceURL)
|
p, err := url.Parse(rl.ServiceURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
relayPathMatches = strings.TrimSuffix(r.URL.Path, "/") == strings.TrimSuffix(p.Path, "/")
|
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)
|
ws.WriteJSON(resp)
|
||||||
|
|
||||||
case *nostr.ReqEnvelope:
|
case *nostr.ReqEnvelope:
|
||||||
rl.removeListenerId(ws, env.SubscriptionID)
|
|
||||||
|
|
||||||
eose := sync.WaitGroup{}
|
eose := sync.WaitGroup{}
|
||||||
eose.Add(len(env.Filters))
|
eose.Add(len(env.Filters))
|
||||||
|
|
||||||
|
|||||||
+64
-141
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"iter"
|
"iter"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"fiatjaf.com/lib/set"
|
"fiatjaf.com/lib/set"
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -32,27 +31,19 @@ type subscription struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dispatcher struct {
|
type dispatcher struct {
|
||||||
serial int
|
serial int
|
||||||
subscriptions *xsync.MapOf[int, subscription]
|
subscriptions *xsync.MapOf[int, subscription]
|
||||||
byAuthor *xsync.MapOf[nostr.PubKey, set.Set[int]]
|
byAuthor map[nostr.PubKey]set.Set[int]
|
||||||
byKind *xsync.MapOf[nostr.Kind, set.Set[int]]
|
byKind map[nostr.Kind]set.Set[int]
|
||||||
fallbackTags set.Set[int]
|
fallback set.Set[int]
|
||||||
fallbackNothing set.Set[int]
|
|
||||||
}
|
|
||||||
|
|
||||||
var setPool = sync.Pool{
|
|
||||||
New: func() any {
|
|
||||||
return set.NewEmptySliceSetReusing[int](make([]int, 0, 10))
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDispatcher() dispatcher {
|
func newDispatcher() dispatcher {
|
||||||
return dispatcher{
|
return dispatcher{
|
||||||
subscriptions: xsync.NewMapOf[int, subscription](),
|
subscriptions: xsync.NewMapOf[int, subscription](),
|
||||||
byAuthor: xsync.NewMapOf[nostr.PubKey, set.Set[int]](),
|
byAuthor: make(map[nostr.PubKey]set.Set[int]),
|
||||||
byKind: xsync.NewMapOf[nostr.Kind, set.Set[int]](),
|
byKind: make(map[nostr.Kind]set.Set[int]),
|
||||||
fallbackTags: setPool.Get().(set.Set[int]),
|
fallback: set.NewSliceSet[int](),
|
||||||
fallbackNothing: setPool.Get().(set.Set[int]),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,128 +57,93 @@ func (d *dispatcher) addSubscription(sub subscription) int {
|
|||||||
if sub.filter.Authors != nil {
|
if sub.filter.Authors != nil {
|
||||||
indexed = true
|
indexed = true
|
||||||
for _, author := range sub.filter.Authors {
|
for _, author := range sub.filter.Authors {
|
||||||
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
s, ok := d.byAuthor[author]
|
||||||
if !loaded {
|
if !ok {
|
||||||
s = setPool.Get().(set.Set[int])
|
s = set.NewSliceSet[int]()
|
||||||
}
|
d.byAuthor[author] = s
|
||||||
s.Add(ssid)
|
}
|
||||||
return s, false
|
s.Add(ssid)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sub.filter.Kinds != nil {
|
if sub.filter.Kinds != nil {
|
||||||
indexed = true
|
indexed = true
|
||||||
for _, kind := range sub.filter.Kinds {
|
for _, kind := range sub.filter.Kinds {
|
||||||
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
s, ok := d.byKind[kind]
|
||||||
if !loaded {
|
if !ok {
|
||||||
s = setPool.Get().(set.Set[int])
|
s = set.NewSliceSet[int]()
|
||||||
}
|
d.byKind[kind] = s
|
||||||
s.Add(ssid)
|
}
|
||||||
return s, false
|
s.Add(ssid)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !indexed {
|
if !indexed {
|
||||||
if sub.filter.Tags != nil {
|
d.fallback.Add(ssid)
|
||||||
d.fallbackTags.Add(ssid)
|
|
||||||
} else {
|
|
||||||
d.fallbackNothing.Add(ssid)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ssid
|
return ssid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dispatcher) removeSubscription(ssid int) nostr.Filter {
|
func (d *dispatcher) removeSubscription(ssid int) {
|
||||||
var filter nostr.Filter
|
sub, ok := d.subscriptions.LoadAndDelete(ssid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
d.subscriptions.Compute(ssid, func(sub subscription, loaded bool) (subscription, bool) {
|
indexed := false
|
||||||
indexed := false
|
if sub.filter.Authors != nil {
|
||||||
|
indexed = true
|
||||||
filter = sub.filter
|
for _, author := range sub.filter.Authors {
|
||||||
|
s, ok := d.byAuthor[author]
|
||||||
if sub.filter.Authors != nil {
|
if !ok {
|
||||||
indexed = true
|
return
|
||||||
for _, author := range sub.filter.Authors {
|
}
|
||||||
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
s.Remove(ssid)
|
||||||
if !loaded {
|
if s.Len() == 0 {
|
||||||
return s, true
|
delete(d.byAuthor, author)
|
||||||
}
|
|
||||||
s.Remove(ssid)
|
|
||||||
|
|
||||||
delete := s.Len() == 0
|
|
||||||
if delete {
|
|
||||||
setPool.Put(s)
|
|
||||||
}
|
|
||||||
return s, delete
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if sub.filter.Kinds != nil {
|
if sub.filter.Kinds != nil {
|
||||||
indexed = true
|
indexed = true
|
||||||
for _, kind := range sub.filter.Kinds {
|
for _, kind := range sub.filter.Kinds {
|
||||||
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
s, ok := d.byKind[kind]
|
||||||
if !loaded {
|
if !ok {
|
||||||
return s, true
|
return
|
||||||
}
|
}
|
||||||
s.Remove(ssid)
|
s.Remove(ssid)
|
||||||
|
if s.Len() == 0 {
|
||||||
delete := s.Len() == 0
|
delete(d.byKind, kind)
|
||||||
if delete {
|
|
||||||
setPool.Put(s)
|
|
||||||
}
|
|
||||||
return s, delete
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !indexed {
|
if !indexed {
|
||||||
if sub.filter.Tags != nil {
|
d.fallback.Remove(ssid)
|
||||||
d.fallbackTags.Remove(ssid)
|
}
|
||||||
} else {
|
|
||||||
d.fallbackNothing.Remove(ssid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sub, true
|
|
||||||
})
|
|
||||||
|
|
||||||
return filter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
||||||
return func(yield func(subscription) bool) {
|
return func(yield func(subscription) bool) {
|
||||||
authorSubs, hasAuthorSubs := d.byAuthor.Load(event.PubKey)
|
authorSubs, hasAuthorSubs := d.byAuthor[event.PubKey]
|
||||||
kindSubs, hasKindSubs := d.byKind.Load(event.Kind)
|
kindSubs, hasKindSubs := d.byKind[event.Kind]
|
||||||
|
|
||||||
if hasAuthorSubs && hasKindSubs {
|
if hasAuthorSubs && hasKindSubs {
|
||||||
for _, ssid := range authorSubs.Slice() {
|
for _, ssid := range authorSubs.Slice() {
|
||||||
sub, _ := d.subscriptions.Load(ssid)
|
sub, _ := d.subscriptions.Load(ssid)
|
||||||
|
|
||||||
if kindSubs.Has(ssid) || sub.filter.Kinds == nil {
|
if kindSubs.Has(ssid) {
|
||||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||||
if !yield(sub) {
|
if !yield(sub) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}
|
// matched author but not tags, so this event doesn't qualify for any filter
|
||||||
|
|
||||||
for _, ssid := range kindSubs.Slice() {
|
|
||||||
sub, _ := d.subscriptions.Load(ssid)
|
|
||||||
|
|
||||||
if sub.filter.Authors != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
|
||||||
if !yield(sub) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if hasAuthorSubs {
|
} else if hasAuthorSubs {
|
||||||
for _, ssid := range authorSubs.Slice() {
|
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.fallback.Slice() {
|
||||||
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() {
|
|
||||||
sub, _ := d.subscriptions.Load(ssid)
|
sub, _ := d.subscriptions.Load(ssid)
|
||||||
if filterMatchesTimestampConstraints(sub.filter, event) {
|
|
||||||
|
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||||
if !yield(sub) {
|
if !yield(sub) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,7 +190,7 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//go:inline
|
//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 {
|
if filter.Since != 0 && event.CreatedAt < filter.Since {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -254,15 +199,6 @@ func filterMatchesTimestampConstraints(filter nostr.Filter, event nostr.Event) b
|
|||||||
return false
|
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 {
|
for f, v := range filter.Tags {
|
||||||
if !event.Tags.ContainsAny(f, v) {
|
if !event.Tags.ContainsAny(f, v) {
|
||||||
return false
|
return false
|
||||||
@@ -311,10 +247,6 @@ func (rl *Relay) addListener(
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
sid: id,
|
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 {
|
for _, spec := range specs {
|
||||||
if spec.sid == id {
|
if spec.sid == id {
|
||||||
spec.cancel(ErrSubscriptionClosedByClient)
|
spec.cancel(ErrSubscriptionClosedByClient)
|
||||||
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
rl.dispatcher.removeSubscription(spec.ssid)
|
||||||
|
|
||||||
if rl.OnListenerRemoved != nil {
|
|
||||||
rl.OnListenerRemoved(ws, spec.ssid, id, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, spec)
|
kept = append(kept, spec)
|
||||||
@@ -349,11 +276,7 @@ func (rl *Relay) removeClientAndListeners(ws *WebSocket) {
|
|||||||
if specs, ok := rl.clients[ws]; ok {
|
if specs, ok := rl.clients[ws]; ok {
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
// no need to cancel contexts since they inherit from the main connection context
|
// no need to cancel contexts since they inherit from the main connection context
|
||||||
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
rl.dispatcher.removeSubscription(spec.ssid)
|
||||||
|
|
||||||
if rl.OnListenerRemoved != nil {
|
|
||||||
rl.OnListenerRemoved(ws, spec.ssid, spec.sid, filter)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(rl.clients, ws)
|
delete(rl.clients, ws)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package khatru
|
package khatru
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -124,7 +125,10 @@ func FuzzRandomListenerIdRemoving(f *testing.F) {
|
|||||||
}
|
}
|
||||||
require.Equal(t, len(subs)+extra, ssidCount)
|
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)
|
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-16
@@ -1,6 +1,7 @@
|
|||||||
package khatru
|
package khatru
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -22,18 +23,6 @@ func idFromSeq(seq int, min, max int) string {
|
|||||||
return result.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) {
|
func TestListenerSetupAndRemoveOnce(t *testing.T) {
|
||||||
rl := NewRelay()
|
rl := NewRelay()
|
||||||
|
|
||||||
@@ -332,7 +321,7 @@ func TestRandomListenerClientRemoving(t *testing.T) {
|
|||||||
ws := websockets[i]
|
ws := websockets[i]
|
||||||
w := idFromSeqUpper(i)
|
w := idFromSeqUpper(i)
|
||||||
|
|
||||||
if (i+j)%2 == 0 {
|
if rand.Intn(2) < 1 {
|
||||||
l++
|
l++
|
||||||
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
|
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
|
||||||
}
|
}
|
||||||
@@ -385,12 +374,12 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
|||||||
ws := websockets[i]
|
ws := websockets[i]
|
||||||
w := idFromSeqUpper(i)
|
w := idFromSeqUpper(i)
|
||||||
|
|
||||||
if (i+j)%2 == 0 {
|
if rand.Intn(2) < 1 {
|
||||||
id := w + ":" + idFromSeqLower(j)
|
id := w + ":" + idFromSeqLower(j)
|
||||||
rl.addListener(ws, id, f, cancel)
|
rl.addListener(ws, id, f, cancel)
|
||||||
subs = append(subs, wsid{ws, id})
|
subs = append(subs, wsid{ws, id})
|
||||||
|
|
||||||
if (i+j)%5 == 0 {
|
if rand.Intn(5) < 1 {
|
||||||
rl.addListener(ws, id, f, cancel)
|
rl.addListener(ws, id, f, cancel)
|
||||||
extra++
|
extra++
|
||||||
}
|
}
|
||||||
@@ -405,7 +394,10 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.Equal(t, len(subs)+extra, ssidCount)
|
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)
|
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ type RelayManagementAPI struct {
|
|||||||
Stats func(ctx context.Context) (nip86.Response, error)
|
Stats func(ctx context.Context) (nip86.Response, error)
|
||||||
GrantAdmin func(ctx context.Context, pubkey nostr.PubKey, methods []string) error
|
GrantAdmin func(ctx context.Context, pubkey nostr.PubKey, methods []string) error
|
||||||
RevokeAdmin func(ctx context.Context, pubkey nostr.PubKey, methods []string) error
|
RevokeAdmin func(ctx context.Context, pubkey nostr.PubKey, methods []string) error
|
||||||
SignEvent func(ctx context.Context, event nostr.Event) (nostr.Event, error)
|
|
||||||
Generic func(ctx context.Context, request nip86.Request) (nip86.Response, error)
|
Generic func(ctx context.Context, request nip86.Request) (nip86.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,14 +346,6 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
resp.Result = result
|
resp.Result = result
|
||||||
}
|
}
|
||||||
case nip86.SignEvent:
|
|
||||||
if rl.ManagementAPI.SignEvent == nil {
|
|
||||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
|
||||||
} else if result, err := rl.ManagementAPI.SignEvent(ctx, thing.Event); err != nil {
|
|
||||||
resp.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
resp.Result = result
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
if rl.ManagementAPI.Generic == nil {
|
if rl.ManagementAPI.Generic == nil {
|
||||||
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
|
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
|
||||||
|
|||||||
+5
-109
@@ -2,8 +2,6 @@ package khatru
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"iter"
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"fiatjaf.com/lib/channelmutex"
|
"fiatjaf.com/lib/channelmutex"
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -71,7 +68,7 @@ type Relay struct {
|
|||||||
// hooks that will be called at various times
|
// hooks that will be called at various times
|
||||||
OnEvent func(ctx context.Context, event nostr.Event) (reject bool, msg string)
|
OnEvent func(ctx context.Context, event nostr.Event) (reject bool, msg string)
|
||||||
StoreEvent func(ctx context.Context, event nostr.Event) error
|
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
|
DeleteEvent func(ctx context.Context, id nostr.ID) error
|
||||||
OnEventSaved func(ctx context.Context, event nostr.Event)
|
OnEventSaved func(ctx context.Context, event nostr.Event)
|
||||||
OnEventDeleted func(ctx context.Context, deleted nostr.Event)
|
OnEventDeleted func(ctx context.Context, deleted nostr.Event)
|
||||||
@@ -84,8 +81,6 @@ type Relay struct {
|
|||||||
RejectConnection func(r *http.Request) bool
|
RejectConnection func(r *http.Request) bool
|
||||||
OnConnect func(ctx context.Context)
|
OnConnect func(ctx context.Context)
|
||||||
OnDisconnect 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
|
OverwriteRelayInformation func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
|
||||||
PreventBroadcast func(ws *WebSocket, filter nostr.Filter, event nostr.Event) bool
|
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 {
|
rl.StoreEvent = func(ctx context.Context, event nostr.Event) error {
|
||||||
return store.SaveEvent(event)
|
return store.SaveEvent(event)
|
||||||
}
|
}
|
||||||
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) error {
|
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) ([]nostr.Event, error) {
|
||||||
_, err := store.ReplaceEvent(event)
|
return store.ReplaceEvent(event)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
rl.DeleteEvent = func(ctx context.Context, id nostr.ID) error {
|
rl.DeleteEvent = func(ctx context.Context, id nostr.ID) error {
|
||||||
return store.DeleteEvent(id)
|
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 {
|
func (rl *Relay) getBaseURL(r *http.Request) string {
|
||||||
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
if rl.ServiceURL != "" {
|
||||||
return serviceURL
|
return rl.ServiceURL
|
||||||
}
|
}
|
||||||
|
|
||||||
host := r.Header.Get("X-Forwarded-Host")
|
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
|
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.
|
// Stats returns the current number of connected clients and open listeners.
|
||||||
func (rl *Relay) Stats() (clients, listeners int) {
|
func (rl *Relay) Stats() (clients, listeners int) {
|
||||||
rl.clientsMutex.Lock()
|
rl.clientsMutex.Lock()
|
||||||
@@ -217,89 +203,6 @@ func (rl *Relay) Stats() (clients, listeners int) {
|
|||||||
return len(rl.clients), listeners
|
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 {
|
func (rl *Relay) Router() *http.ServeMux {
|
||||||
return rl.serveMux
|
return rl.serveMux
|
||||||
}
|
}
|
||||||
@@ -307,10 +210,3 @@ func (rl *Relay) Router() *http.ServeMux {
|
|||||||
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
||||||
rl.serveMux = mux
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math"
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,15 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func FuzzReplaceableEvents(f *testing.F) {
|
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 {
|
if nevents == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := fuzzState{value: seed, advance: advance}
|
|
||||||
|
|
||||||
relay := NewRelay()
|
relay := NewRelay()
|
||||||
store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"}
|
store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"}
|
||||||
store.Init()
|
store.Init()
|
||||||
@@ -68,10 +67,12 @@ func FuzzReplaceableEvents(f *testing.F) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
rnd := rand.New(rand.NewPCG(uint64(seed), 0))
|
||||||
|
|
||||||
newest := nostr.Timestamp(0)
|
newest := nostr.Timestamp(0)
|
||||||
for range nevents {
|
for range nevents {
|
||||||
evt := createEvent(sk1, 0, `{"name":"blblbl"}`, nil)
|
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)
|
evt.Sign(sk1)
|
||||||
err = client1.Publish(ctx, evt)
|
err = client1.Publish(ctx, evt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ package khatru
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -12,60 +9,9 @@ import (
|
|||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/eventstore/slicestore"
|
"fiatjaf.com/nostr/eventstore/slicestore"
|
||||||
"fiatjaf.com/nostr/nip11"
|
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestBasicRelayFunctionality(t *testing.T) {
|
||||||
// setup relay with in-memory store
|
// setup relay with in-memory store
|
||||||
relay := NewRelay()
|
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
|
go test fuzz v1
|
||||||
int(25)
|
uint(25)
|
||||||
int(1)
|
|
||||||
uint(223)
|
uint(223)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const (
|
|||||||
subscriptionIdKey
|
subscriptionIdKey
|
||||||
nip86HeaderAuthKey
|
nip86HeaderAuthKey
|
||||||
internalCallKey
|
internalCallKey
|
||||||
serviceURLOverrideKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequestAuth(ctx context.Context) {
|
func RequestAuth(ctx context.Context) {
|
||||||
@@ -74,13 +73,6 @@ func IsAuthed(ctx context.Context, pubkey nostr.PubKey) bool {
|
|||||||
return false
|
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
|
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
|
||||||
// or expiration request.
|
// or expiration request.
|
||||||
func IsInternalCall(ctx context.Context) bool {
|
func IsInternalCall(ctx context.Context) bool {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package khatru
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"github.com/fasthttp/websocket"
|
"github.com/fasthttp/websocket"
|
||||||
@@ -34,13 +31,6 @@ type WebSocket struct {
|
|||||||
negentropySessions *xsync.MapOf[string, *NegentropySession]
|
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 {
|
func (ws *WebSocket) WriteJSON(any any) error {
|
||||||
if ws == nil {
|
if ws == nil {
|
||||||
return fmt.Errorf("connection doesn't exist")
|
return fmt.Errorf("connection doesn't exist")
|
||||||
|
|||||||
@@ -274,10 +274,6 @@ func (kind Kind) Name() string {
|
|||||||
return "VideoViewEvent"
|
return "VideoViewEvent"
|
||||||
case KindCommunityDefinition:
|
case KindCommunityDefinition:
|
||||||
return "CommunityDefinition"
|
return "CommunityDefinition"
|
||||||
case KindNsiteRoot:
|
|
||||||
return "NsiteRoot"
|
|
||||||
case KindNsiteNamed:
|
|
||||||
return "NsiteNamed"
|
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
@@ -364,7 +360,6 @@ const (
|
|||||||
KindGoodWikiAuthorList Kind = 10101
|
KindGoodWikiAuthorList Kind = 10101
|
||||||
KindGoodWikiRelayList Kind = 10102
|
KindGoodWikiRelayList Kind = 10102
|
||||||
KindNWCWalletInfo Kind = 13194
|
KindNWCWalletInfo Kind = 13194
|
||||||
KindNsiteRoot Kind = 15128
|
|
||||||
KindLightningPubRPC Kind = 21000
|
KindLightningPubRPC Kind = 21000
|
||||||
KindClientAuthentication Kind = 22242
|
KindClientAuthentication Kind = 22242
|
||||||
KindNWCWalletRequest Kind = 23194
|
KindNWCWalletRequest Kind = 23194
|
||||||
@@ -399,7 +394,6 @@ const (
|
|||||||
KindDraftClassifiedListing Kind = 30403
|
KindDraftClassifiedListing Kind = 30403
|
||||||
KindRepositoryAnnouncement Kind = 30617
|
KindRepositoryAnnouncement Kind = 30617
|
||||||
KindRepositoryState Kind = 30618
|
KindRepositoryState Kind = 30618
|
||||||
KindNsiteNamed Kind = 35128
|
|
||||||
KindSimpleGroupMetadata Kind = 39000
|
KindSimpleGroupMetadata Kind = 39000
|
||||||
KindSimpleGroupAdmins Kind = 39001
|
KindSimpleGroupAdmins Kind = 39001
|
||||||
KindSimpleGroupMembers Kind = 39002
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package nip86
|
package nip86
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
@@ -243,25 +242,6 @@ func DecodeRequest(req Request) (MethodParams, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
case "stats":
|
case "stats":
|
||||||
return Stats{}, nil
|
return Stats{}, nil
|
||||||
case "signevent":
|
|
||||||
if len(req.Params) == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// params[0] is an unsigned event template {kind, content, tags,
|
|
||||||
// created_at}; it arrives as a decoded JSON value, so round-trip it
|
|
||||||
// through JSON into an Event.
|
|
||||||
evtJSON, err := json.Marshal(req.Params[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid event param for '%s'", req.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
var evt nostr.Event
|
|
||||||
if err := json.Unmarshal(evtJSON, &evt); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid event param for '%s'", req.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SignEvent{Event: evt}, nil
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown method '%s'", req.Method)
|
return nil, fmt.Errorf("unknown method '%s'", req.Method)
|
||||||
}
|
}
|
||||||
@@ -297,7 +277,6 @@ var (
|
|||||||
_ MethodParams = (*GrantAdmin)(nil)
|
_ MethodParams = (*GrantAdmin)(nil)
|
||||||
_ MethodParams = (*RevokeAdmin)(nil)
|
_ MethodParams = (*RevokeAdmin)(nil)
|
||||||
_ MethodParams = (*Stats)(nil)
|
_ MethodParams = (*Stats)(nil)
|
||||||
_ MethodParams = (*SignEvent)(nil)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SupportedMethods struct{}
|
type SupportedMethods struct{}
|
||||||
@@ -439,11 +418,3 @@ func (RevokeAdmin) MethodName() string { return "revokeadmin" }
|
|||||||
type Stats struct{}
|
type Stats struct{}
|
||||||
|
|
||||||
func (Stats) MethodName() string { return "stats" }
|
func (Stats) MethodName() string { return "stats" }
|
||||||
|
|
||||||
// SignEvent asks the relay to sign an unsigned event template with its own
|
|
||||||
// (self) key and return the full signed event. See NIP-86 `signevent`.
|
|
||||||
type SignEvent struct {
|
|
||||||
Event nostr.Event
|
|
||||||
}
|
|
||||||
|
|
||||||
func (SignEvent) MethodName() string { return "signevent" }
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package blossom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,17 +11,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Download downloads a file from the media server by its hash
|
// Download downloads a file from the media server by its hash
|
||||||
func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) {
|
func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) {
|
||||||
hhash := hex.EncodeToString(hash[:])
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
||||||
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
|
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)
|
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
|
// 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 {
|
func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error {
|
||||||
hhash := hex.EncodeToString(hash[:])
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
|
||||||
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
|
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)
|
req.Header.Add("Authorization", authHeader)
|
||||||
|
|
||||||
|
|||||||
@@ -313,9 +313,7 @@ func easyjson33014d6eEncodeFiatjafComNostr2(out *jwriter.Writer, in EntityPointe
|
|||||||
out.RawString(prefix)
|
out.RawString(prefix)
|
||||||
out.Uint(uint(in.Kind))
|
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\":"
|
const prefix string = ",\"identifier\":"
|
||||||
out.RawString(prefix)
|
out.RawString(prefix)
|
||||||
out.String(in.Identifier)
|
out.String(in.Identifier)
|
||||||
|
|||||||
@@ -24,26 +24,13 @@ type Pool struct {
|
|||||||
Relays *xsync.MapOf[string, *Relay]
|
Relays *xsync.MapOf[string, *Relay]
|
||||||
Context context.Context
|
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.
|
EventMiddleware func(RelayEvent)
|
||||||
// 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.
|
|
||||||
DuplicateMiddleware func(relay string, id ID)
|
DuplicateMiddleware func(relay string, id ID)
|
||||||
|
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
||||||
// AuthorKindQueryMiddleware is a function that will be called with every combination of
|
RelayOptions RelayOptions
|
||||||
// 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
|
|
||||||
|
|
||||||
// custom things not often used
|
// custom things not often used
|
||||||
penaltyBox *xsync.MapOf[string, [2]float64]
|
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() {
|
func (pool *Pool) StartPenaltyBox() {
|
||||||
pool.penaltyBox = xsync.NewMapOf[string, [2]float64]()
|
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 {
|
if err := relay.Publish(ctx, evt); err == nil {
|
||||||
// success with no auth required
|
// success with no auth required
|
||||||
ch <- PublishResult{nil, url, relay}
|
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
|
// 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 {
|
if err := relay.Publish(ctx, evt); err == nil {
|
||||||
// success after auth
|
// success after auth
|
||||||
ch <- PublishResult{nil, url, relay}
|
ch <- PublishResult{nil, url, relay}
|
||||||
@@ -382,9 +394,9 @@ func (pool *Pool) FetchManyReplaceable(
|
|||||||
case <-sub.EndOfStoredEvents:
|
case <-sub.EndOfStoredEvents:
|
||||||
return
|
return
|
||||||
case reason := <-sub.ClosedReason:
|
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
|
// 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 {
|
if err == nil {
|
||||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||||
goto subscribe
|
goto subscribe
|
||||||
@@ -558,9 +570,9 @@ func (pool *Pool) subMany(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case reason := <-sub.ClosedReason:
|
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
|
// 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 {
|
if err == nil {
|
||||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||||
if closedChan != nil {
|
if closedChan != nil {
|
||||||
@@ -665,9 +677,9 @@ func (pool *Pool) subManyEose(
|
|||||||
case <-sub.EndOfStoredEvents:
|
case <-sub.EndOfStoredEvents:
|
||||||
return
|
return
|
||||||
case reason := <-sub.ClosedReason:
|
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
|
// 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 {
|
if err == nil {
|
||||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||||
if closedChan != nil {
|
if closedChan != nil {
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ func NewRelay(ctx context.Context, url string, opts RelayOptions) *Relay {
|
|||||||
noticeHandler: opts.NoticeHandler,
|
noticeHandler: opts.NoticeHandler,
|
||||||
authHandler: opts.AuthHandler,
|
authHandler: opts.AuthHandler,
|
||||||
closed: &atomic.Bool{},
|
closed: &atomic.Bool{},
|
||||||
AssumeValid: opts.AssumeValid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -148,9 +147,6 @@ type RelayOptions struct {
|
|||||||
|
|
||||||
// RequestHeader sets the HTTP request header of the websocket preflight request
|
// RequestHeader sets the HTTP request header of the websocket preflight request
|
||||||
RequestHeader http.Header
|
RequestHeader http.Header
|
||||||
|
|
||||||
// AssumeValid disables signature verification for events received from this relay
|
|
||||||
AssumeValid bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// String just returns the relay URL.
|
// String just returns the relay URL.
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ func (rte RequiredTagError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) ValidateEvent(evt nostr.Event) error {
|
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 sch, ok := v.Schema.Kinds[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
|
||||||
if validator, ok := v.TypeValidators[sch.Content.Type]; ok {
|
if validator, ok := v.TypeValidators[sch.Content.Type]; ok {
|
||||||
if err := validator(evt.Content, &sch.Content); err != nil {
|
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]]
|
MediaFollowListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||||
goodWikiAuthorListCacheOnce sync.Once
|
goodWikiAuthorListCacheOnce sync.Once
|
||||||
GoodWikiAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
GoodWikiAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||||
blossomServerListCacheOnce sync.Once
|
|
||||||
BlossomServerListCache cache.Cache32[GenericList[string, BlossomURL]]
|
|
||||||
gitAuthorListCacheOnce sync.Once
|
gitAuthorListCacheOnce sync.Once
|
||||||
GitAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
GitAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
|
||||||
relaySetsCacheOnce sync.Once
|
relaySetsCacheOnce sync.Once
|
||||||
|
|||||||
+35
-13
@@ -19,17 +19,18 @@ type wotCall struct {
|
|||||||
id uint64 // basically the pubkey we're targeting here
|
id uint64 // basically the pubkey we're targeting here
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
resultbacks []chan WotXorFilter // all callers waiting for results
|
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
|
done chan struct{} // this is closed when this call is fully resolved and deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
const wotCallsSize = 16
|
const wotCallsSize = 8
|
||||||
|
|
||||||
var (
|
var (
|
||||||
wotCallsMutex sync.Mutex
|
wotCallsMutex sync.Mutex
|
||||||
wotCallsInPlace [wotCallsSize]*wotCall
|
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)
|
id := PubKeyToShid(pubkey)
|
||||||
pos := int(id % wotCallsSize)
|
pos := int(id % wotCallsSize)
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ start:
|
|||||||
wc = &wotCall{
|
wc = &wotCall{
|
||||||
id: id,
|
id: id,
|
||||||
resultbacks: make([]chan WotXorFilter, 0),
|
resultbacks: make([]chan WotXorFilter, 0),
|
||||||
|
errorbacks: make([]chan error, 0),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
wotCallsInPlace[pos] = wc
|
wotCallsInPlace[pos] = wc
|
||||||
@@ -52,13 +54,17 @@ start:
|
|||||||
|
|
||||||
wc.mutex.Lock()
|
wc.mutex.Lock()
|
||||||
if wc.id == id {
|
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)
|
resch := make(chan WotXorFilter)
|
||||||
|
errch := make(chan error)
|
||||||
wc.resultbacks = append(wc.resultbacks, resch)
|
wc.resultbacks = append(wc.resultbacks, resch)
|
||||||
|
wc.errorbacks = append(wc.errorbacks, errch)
|
||||||
wc.mutex.Unlock()
|
wc.mutex.Unlock()
|
||||||
select {
|
select {
|
||||||
case res := <-resch:
|
case res := <-resch:
|
||||||
return res
|
return res, nil
|
||||||
|
case err := <-errch:
|
||||||
|
return WotXorFilter{}, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wc.mutex.Unlock()
|
wc.mutex.Unlock()
|
||||||
@@ -70,11 +76,18 @@ start:
|
|||||||
|
|
||||||
actualcall:
|
actualcall:
|
||||||
var res WotXorFilter
|
var res WotXorFilter
|
||||||
m := sys.loadWoT(ctx, pubkey)
|
m, err := sys.loadWoT(ctx, pubkey)
|
||||||
res = makeWoTFilter(m)
|
if err != nil {
|
||||||
wc.mutex.Lock()
|
wc.mutex.Lock()
|
||||||
for _, ch := range wc.resultbacks {
|
for _, ch := range wc.errorbacks {
|
||||||
ch <- res
|
ch <- err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = makeWoTFilter(m)
|
||||||
|
wc.mutex.Lock()
|
||||||
|
for _, ch := range wc.resultbacks {
|
||||||
|
ch <- res
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wotCallsMutex.Lock()
|
wotCallsMutex.Lock()
|
||||||
@@ -83,17 +96,23 @@ actualcall:
|
|||||||
close(wc.done)
|
close(wc.done)
|
||||||
wotCallsMutex.Unlock()
|
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, ctx := errgroup.WithContext(ctx)
|
||||||
g.SetLimit(45)
|
g.SetLimit(45)
|
||||||
|
|
||||||
res := make(chan nostr.PubKey)
|
res := make(chan nostr.PubKey)
|
||||||
|
|
||||||
|
// process follow lists
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for _, f := range sys.FetchFollowList(ctx, pubkey).Items {
|
for _, f := range sys.FetchFollowList(ctx, pubkey).Items {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
res <- f.Pubkey
|
res <- f.Pubkey
|
||||||
|
|
||||||
@@ -104,17 +123,20 @@ func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) chan nostr.
|
|||||||
for _, f2 := range ff {
|
for _, f2 := range ff {
|
||||||
res <- f2.Pubkey
|
res <- f2.Pubkey
|
||||||
}
|
}
|
||||||
|
wg.Done()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
g.Wait()
|
wg.Wait()
|
||||||
close(res)
|
close(res)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return res
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
|
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
|
||||||
|
|||||||
+7
-4
@@ -3,6 +3,8 @@
|
|||||||
package nostr
|
package nostr
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
||||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
@@ -32,8 +34,8 @@ func (evt Event) VerifySignature() bool {
|
|||||||
sig := schnorr.NewSignature(&r, &s)
|
sig := schnorr.NewSignature(&r, &s)
|
||||||
|
|
||||||
// check signature
|
// check signature
|
||||||
evt.SetID()
|
hash := sha256.Sum256(evt.Serialize())
|
||||||
return sig.Verify(evt.ID[:], pubkey)
|
return sig.Verify(hash[:], pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign signs an event with a given privateKey.
|
// Sign signs an event with a given privateKey.
|
||||||
@@ -50,12 +52,13 @@ func (evt *Event) Sign(secretKey [32]byte) error {
|
|||||||
pkBytes := pk.SerializeCompressed()[1:]
|
pkBytes := pk.SerializeCompressed()[1:]
|
||||||
evt.PubKey = PubKey(pkBytes)
|
evt.PubKey = PubKey(pkBytes)
|
||||||
|
|
||||||
evt.SetID()
|
h := sha256.Sum256(evt.Serialize())
|
||||||
sig, err := schnorr.Sign(sk, evt.ID[:], schnorr.FastSign())
|
sig, err := schnorr.Sign(sk, h[:], schnorr.FastSign())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evt.ID = h
|
||||||
sigb := sig.Serialize()
|
sigb := sig.Serialize()
|
||||||
evt.Sig = [64]byte(sigb)
|
evt.Sig = [64]byte(sigb)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import "C"
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -32,14 +33,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (evt Event) VerifySignature() bool {
|
func (evt Event) VerifySignature() bool {
|
||||||
evt.SetID()
|
msg := sha256.Sum256(evt.Serialize())
|
||||||
|
|
||||||
var xonly C.secp256k1_xonly_pubkey
|
var xonly C.secp256k1_xonly_pubkey
|
||||||
if C.secp256k1_xonly_pubkey_parse(globalSecp256k1Context, &xonly, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0]))) != 1 {
|
if C.secp256k1_xonly_pubkey_parse(globalSecp256k1Context, &xonly, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0]))) != 1 {
|
||||||
return false
|
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
|
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_keypair_xonly_pub(globalSecp256k1Context, &xonly, nil, &keypair)
|
||||||
C.secp256k1_xonly_pubkey_serialize(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0])), &xonly)
|
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
|
var random [32]byte
|
||||||
rand.Read(random[:])
|
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")
|
return errors.New("failed to sign message")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evt.ID = h
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,19 +124,3 @@ func (tags Tags) ContainsAny(tagName string, values []string) bool {
|
|||||||
|
|
||||||
return false
|
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