2 Commits

Author SHA1 Message Date
Jon Staab 4daeb8737c Add unbanpubkey and unallowpubkey 2026-04-14 08:12:49 -07:00
Jon Staab 7ab69cbc60 Switch supported_nips to strings 2026-04-14 08:12:49 -07:00
39 changed files with 293 additions and 2008 deletions
+13 -213
View File
@@ -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
View File
@@ -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
View File
@@ -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
-2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
-265
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -1
View File
@@ -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
View File
@@ -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)
} }
-9
View File
@@ -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
View File
@@ -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))
})
}
+6 -5
View File
@@ -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 {
-54
View File
@@ -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)
-8
View File
@@ -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 {
-10
View File
@@ -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")
-6
View File
@@ -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
-38
View File
@@ -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
View File
@@ -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
}
-237
View File
@@ -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
}
-29
View File
@@ -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" }
+12 -9
View File
@@ -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)
+1 -3
View File
@@ -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)
+38 -26
View File
@@ -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 {
-4
View File
@@ -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.
+4
View File
@@ -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 {
-34
View File
@@ -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
}
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -4
View File
@@ -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
} }
-16
View File
@@ -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
}
-500
View File
File diff suppressed because one or more lines are too long