Compare commits

27 Commits

Author SHA1 Message Date
Jon Staab fefc85d500 Add unbanpubkey and unallowpubkey 2026-05-05 11:36:42 -07:00
Jon Staab fbc805a5a6 Switch supported_nips to strings 2026-05-05 11:36:42 -07:00
fiatjaf a5aeff31d7 khatru: cancel existing subscription when a new one starts with the same id. 2026-05-04 19:26:24 -03:00
fiatjaf bf7998e780 khatru: OnListenerRemoved wasn't being called in the most common case of a connection dropped. 2026-05-04 13:13:02 -03:00
fiatjaf 61586d5d1b khatru: ForceSetAuthed() 2026-05-04 11:50:21 -03:00
fiatjaf c75bd45d13 Tags.Eq() 2026-05-04 11:50:21 -03:00
fiatjaf aafff41d40 mmm: rawread stats. 2026-05-03 13:48:19 -03:00
fiatjaf cbf335a8fa schema: dangling space is not a problem in content. 2026-05-03 13:47:23 -03:00
fiatjaf 05b426e67e khatru: add and remove listener hooks. 2026-04-29 19:32:47 -03:00
fiatjaf 744fb0702c relay.AssumeValid can be passed as an option, so it works from a Pool. 2026-04-23 22:16:47 -03:00
fiatjaf b899ef8865 faster signature verification by serializing directly into the sha with less allocations. 2026-04-23 22:16:46 -03:00
fiatjaf 696f377109 event verification benchmark. 2026-04-23 22:16:23 -03:00
fiatjaf e144b33fa2 khatru: use sync.Pool to minimize allocations of sets on dispatcher. 2026-04-23 20:14:18 -03:00
fiatjaf 42379e53a2 sdk: get rid of unused error returns in wot. 2026-04-23 08:25:55 -03:00
fiatjaf e2ad68d050 khatru: we haven't fixed the nil ws bug on dispatcher, but at least now we have more tests and an even more efficient architecture! 2026-04-22 23:16:43 -03:00
fiatjaf 223d95461f blossom/nsite tweaks. 2026-04-22 15:52:50 -03:00
fiatjaf 078ee94465 sdk: FetchBlossomServerList(). 2026-04-22 15:16:46 -03:00
fiatjaf a21ea55eaa nip5A: nsites. 2026-04-22 15:08:01 -03:00
fiatjaf 5b28d08e47 khatru: add tests and fix dispatcher. 2026-04-21 21:20:40 -03:00
fiatjaf 94ea432818 delete PoolOptions entirely (it should have been deleted earlier) and expose AuthRequiredHandler field. 2026-04-19 20:14:00 -03:00
fiatjaf 8200164174 don't print identifier in replaceable pointers when all is ok. 2026-04-18 15:02:58 -03:00
fiatjaf f50b7b0f8d khatru: list clients and client details. 2026-04-16 16:14:42 -03:00
fiatjaf 31473172a9 khatru: byAuthor and byKind as xsync maps. 2026-04-16 07:20:48 -03:00
fiatjaf d56bdba3ff khatru: WithServiceURL() subhandlers. 2026-04-15 21:19:03 -03:00
fiatjaf 7dc553f71b eventstore/bleve: when there is only one language we skip using the detector. 2026-04-14 21:38:43 -03:00
fiatjaf fbd4dddba3 eventstore/bleve: index some generic tags and references on all events. 2026-04-14 20:40:28 -03:00
fiatjaf c11e94a04b khatru: ReplaceEvent hook doesn't need the previous events. 2026-04-14 19:58:17 -03:00
44 changed files with 2070 additions and 323 deletions
+213 -13
View File
@@ -2,7 +2,9 @@ package nostr
import (
"crypto/sha256"
"hash"
"strconv"
"unsafe"
"github.com/mailru/easyjson"
"github.com/templexxx/xhex"
@@ -26,10 +28,17 @@ func (evt Event) String() string {
// GetID serializes and returns the event ID as a string.
func (evt Event) GetID() ID {
return sha256.Sum256(evt.Serialize())
var id ID
evt.serializedHash(&id)
return id
}
// CheckID checks if the implied ID matches the given ID more efficiently.
// SetID calculates and sets the id to the event in a single operation.
func (evt *Event) SetID() {
evt.serializedHash(&evt.ID)
}
// CheckID checks if the implied ID matches the currently assigned ID.
func (evt Event) CheckID() bool {
return evt.GetID() == evt.ID
}
@@ -38,17 +47,56 @@ func (evt Event) CheckID() bool {
func (evt Event) Serialize() []byte {
// the serialization process is just putting everything into a JSON array
// so the order is kept. See NIP-01
dst := make([]byte, 4+64, 100+len(evt.Content)+len(evt.Tags)*80)
dst := make([]byte, 0, 100+len(evt.Content)+len(evt.Tags)*80)
return evt.appendSerialized(dst)
}
// the header portion is easy to serialize
// [0,"pubkey",created_at,kind,[
copy(dst, `[0,"`)
xhex.Encode(dst[4:4+64], evt.PubKey[:]) // there will always be such capacity
var escTable [256]bool
// pre-built escape sequences; index by the offending byte.
var escSeq [256][2]byte
// pre-built []byte slices for hash.Write calls (no per-call allocation).
var escSlice [256][]byte
var (
jsonQuote = []byte{'"'}
serializedStart = []byte(`[0,"`)
serializedPubkeyEnd = []byte(`",`)
serializedTagsEnd = []byte("],")
serializedTagStart = []byte{'['}
serializedTagEnd = []byte{']'}
serializedComma = []byte{','}
serializedEnd = []byte{']'}
)
func init() {
for _, b := range []byte{'"', '\\', '\n', '\r', '\t'} {
escTable[b] = true
}
escSeq['"'] = [2]byte{'\\', '"'}
escSeq['\\'] = [2]byte{'\\', '\\'}
escSeq['\n'] = [2]byte{'\\', 'n'}
escSeq['\r'] = [2]byte{'\\', 'r'}
escSeq['\t'] = [2]byte{'\\', 't'}
for b, seq := range escSeq {
if escTable[b] {
escSlice[b] = seq[:]
}
}
}
func (evt Event) appendSerialized(dst []byte) []byte {
start := len(dst)
dst = append(dst, `[0,"`...)
dst = append(dst, make([]byte, 64)...)
xhex.Encode(dst[start+4:start+4+64], evt.PubKey[:])
dst = append(dst, `",`...)
dst = append(dst, strconv.FormatInt(int64(evt.CreatedAt), 10)...)
dst = append(dst, `,`...)
dst = append(dst, strconv.FormatUint(uint64(evt.Kind), 10)...)
dst = append(dst, `,`...)
dst = strconv.AppendInt(dst, int64(evt.CreatedAt), 10)
dst = append(dst, ',')
dst = strconv.AppendUint(dst, uint64(evt.Kind), 10)
dst = append(dst, ',')
// tags
dst = append(dst, '[')
@@ -62,15 +110,167 @@ func (evt Event) Serialize() []byte {
if i > 0 {
dst = append(dst, ',')
}
dst = escapeString(dst, s)
dst = appendJSONString(dst, s)
}
dst = append(dst, ']')
}
dst = append(dst, "],"...)
// content needs to be escaped in general as it is user generated.
dst = escapeString(dst, evt.Content)
dst = appendJSONString(dst, evt.Content)
dst = append(dst, ']')
return dst
}
func (evt Event) serializedHash(dst *ID) {
h := sha256.New()
h.Write(serializedStart)
var pubkeyHex [64]byte
xhex.Encode(pubkeyHex[:], evt.PubKey[:])
h.Write(pubkeyHex[:])
h.Write(serializedPubkeyEnd)
var numBuf [20]byte
b := strconv.AppendInt(numBuf[:0], int64(evt.CreatedAt), 10)
h.Write(b)
h.Write(serializedComma)
b = strconv.AppendUint(numBuf[:0], uint64(evt.Kind), 10)
h.Write(b)
h.Write(serializedComma)
h.Write(serializedTagStart)
for i, tag := range evt.Tags {
if i > 0 {
h.Write(serializedComma)
}
h.Write(serializedTagStart)
for j, s := range tag {
if j > 0 {
h.Write(serializedComma)
}
writeJSONString(h, s)
}
h.Write(serializedTagEnd)
}
h.Write(serializedTagsEnd)
writeJSONString(h, evt.Content)
h.Write(serializedEnd)
h.Sum((*dst)[:0])
}
// ── SWAR helper ──────────────────────────────────────────────────────────────
// hasSpecial returns non-zero if any byte in w is one of: \t 0x09, \n 0x0A,
// " 0x22, \ 0x5C. Uses the classic "hasvalue" bit-trick — no branches, no
// memory, pure ALU. Works regardless of endianness because we only care
// whether a match exists, not where.
//
//go:nosplit
func hasSpecial(w uint64) bool {
match := func(w, v uint64) uint64 {
x := w ^ (0x0101010101010101 * v)
return (x - 0x0101010101010101) & ^x & 0x8080808080808080
}
return match(w, 0x09)|match(w, 0x0A)|match(w, 0x0D)|match(w, 0x22)|match(w, 0x5C) != 0
}
func appendJSONString(dst []byte, s string) []byte {
dst = append(dst, '"')
n := len(s)
if n == 0 {
return append(dst, '"')
}
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
start, i := 0, 0
// consume 8 bytes at a time;
// if the whole word is clean, advance without touching dst at all;
// but when a word is dirty, fall back to the byte loop only for that 8-byte window
for i+8 <= n {
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
if hasSpecial(w) {
for end := i + 8; i < end; i++ {
if escTable[s[i]] {
// append everything since the start or the last time we did this up to here
dst = append(dst, s[start:i]...)
// append this special sequence
seq := escSeq[s[i]]
dst = append(dst, seq[0], seq[1])
// set this as a checkpoint
start = i + 1
}
}
} else {
i += 8
}
}
// scalar tail for the remaining <8 bytes (same logic used for the hasSpecial branch above)
for ; i < n; i++ {
if escTable[s[i]] {
dst = append(dst, s[start:i]...)
seq := escSeq[s[i]]
dst = append(dst, seq[0], seq[1])
start = i + 1
}
}
// add the remaining chunk (in a string without any specials this will add everything at once)
dst = append(dst, s[start:]...)
return append(dst, '"')
}
func writeJSONString(h hash.Hash, s string) {
h.Write(jsonQuote)
n := len(s)
if n == 0 {
h.Write(jsonQuote)
return
}
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
start, i := 0, 0
for i+8 <= n {
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
// apply same logic as of appendJSONString()
if hasSpecial(w) {
for end := i + 8; i < end; i++ {
if escTable[s[i]] {
if i > start {
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
}
h.Write(escSlice[s[i]])
start = i + 1
}
}
} else {
i += 8
}
}
for ; i < n; i++ {
if escTable[s[i]] {
if i > start {
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
}
h.Write(escSlice[s[i]])
start = i + 1
}
}
if start < n {
h.Write(unsafe.Slice(unsafe.StringData(s[start:]), len(s)-start))
}
h.Write(jsonQuote)
}
+48 -18
View File
@@ -1,8 +1,12 @@
package nostr
import (
"bufio"
"bytes"
"fmt"
"io"
"math/rand/v2"
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -102,23 +106,49 @@ func TestIDCheck(t *testing.T) {
}
}
func BenchmarkIDCheck(b *testing.B) {
evt := Event{
CreatedAt: Timestamp(rand.Int64N(9999999)),
Content: fmt.Sprintf("hello"),
Tags: Tags{},
func BenchmarkEventVerifySignatureJSONL(b *testing.B) {
events := loadBenchmarkEvents(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, evt := range events {
if !evt.VerifySignature() {
b.Fatal("signature verification failed")
}
}
}
evt.Sign(Generate())
b.Run("naïve", func(b *testing.B) {
for b.Loop() {
_ = evt.GetID() == evt.ID
}
})
b.Run("big brain", func(b *testing.B) {
for b.Loop() {
_ = evt.CheckID()
}
})
}
func loadBenchmarkEvents(b *testing.B) []Event {
b.Helper()
f, err := os.Open("testdata/events.jsonl")
require.NoError(b, err)
b.Cleanup(func() { _ = f.Close() })
r := bufio.NewReader(f)
events := make([]Event, 0, 1024)
for {
line, err := r.ReadBytes('\n')
if err != nil && err != io.EOF {
require.NoError(b, err)
}
line = bytes.TrimSpace(line)
if len(line) != 0 {
var evt Event
require.NoError(b, json.Unmarshal(line, &evt))
require.True(b, evt.VerifySignature(), "fixture contains invalid signature")
events = append(events, evt)
}
if err == io.EOF {
break
}
}
require.NotEmpty(b, events)
return events
}
+33 -23
View File
@@ -202,9 +202,12 @@ func (b *BleveBackend) Init() error {
}
b.index = index
b.detector = lingua.NewLanguageDetectorBuilder().
FromLanguages(b.Languages...).
Build()
if len(b.Languages) >= 2 {
b.detector = lingua.NewLanguageDetectorBuilder().
FromLanguages(b.Languages...).
Build()
}
return nil
}
@@ -248,25 +251,25 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
references = append(references, pm.NIP05)
}
case 9802:
for _, tag := range evt.Tags {
if len(tag) < 2 {
continue
}
for _, tag := range evt.Tags {
if len(tag) < 2 {
continue
}
switch tag[0] {
case "comment", "name", "title", "about", "description":
evt.Content += "\n\n" + tag[1]
case "e":
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
references = append(references, ptr.AsTagReference())
}
switch tag[0] {
case "comment":
evt.Content += "\n\n" + tag[1]
case "e":
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
references = append(references, ptr.AsTagReference())
}
case "a":
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
references = append(references, ptr.AsTagReference())
}
case "r":
references = append(references, tag[1])
case "a":
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
references = append(references, ptr.AsTagReference())
}
case "r":
references = append(references, tag[1])
}
}
@@ -291,9 +294,16 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
}
indexableContent := content.String()
lang, ok := b.detector.DetectLanguageOf(indexableContent)
if !ok {
lang = lingua.English
var lang lingua.Language
if len(b.Languages) == 1 {
lang = b.Languages[0]
} else {
var ok bool
lang, ok = b.detector.DetectLanguageOf(indexableContent)
if !ok {
lang = lingua.English
}
}
var analyzerLangCode string
+2
View File
@@ -42,6 +42,8 @@ func (il *IndexingLayer) ComputeStats(opts StatsOptions) (EventStats, error) {
}
err := il.lmdbEnv.View(func(txn *lmdb.Txn) error {
txn.RawRead = true
cursor, err := txn.OpenCursor(il.indexPubkeyKind)
if err != nil {
return err
+1 -1
View File
@@ -40,7 +40,7 @@ require (
)
require (
fiatjaf.com/lib v0.3.6
fiatjaf.com/lib v0.3.7
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/go-git/go-git/v5 v5.16.3
github.com/pemistahl/lingua-go v1.4.0
+2 -2
View File
@@ -1,5 +1,5 @@
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
-40
View File
@@ -92,46 +92,6 @@ func similarPublicKey(as, bs []PubKey) bool {
return true
}
// Escaping strings for JSON encoding according to RFC8259.
// Also encloses result in quotation marks "".
func escapeString(dst []byte, s string) []byte {
dst = append(dst, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"':
// quotation mark
dst = append(dst, []byte{'\\', '"'}...)
case c == '\\':
// reverse solidus
dst = append(dst, []byte{'\\', '\\'}...)
case c >= 0x20:
// default, rest below are control chars
dst = append(dst, c)
case c == 0x08:
dst = append(dst, []byte{'\\', 'b'}...)
case c < 0x09:
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...)
case c == 0x09:
dst = append(dst, []byte{'\\', 't'}...)
case c == 0x0a:
dst = append(dst, []byte{'\\', 'n'}...)
case c == 0x0c:
dst = append(dst, []byte{'\\', 'f'}...)
case c == 0x0d:
dst = append(dst, []byte{'\\', 'r'}...)
case c < 0x10:
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...)
case c < 0x1a:
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...)
case c < 0x20:
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...)
}
}
dst = append(dst, '"')
return dst
}
func subIdToSerial(subId string) int64 {
n := strings.Index(subId, ":")
if n < 0 || n > len(subId) {
+1 -1
View File
@@ -46,7 +46,7 @@ func (rl *Relay) handleNormal(ctx context.Context, evt nostr.Event) (skipBroadca
} else {
// otherwise it's a replaceable
if nil != rl.ReplaceEvent {
if _, err := rl.ReplaceEvent(ctx, evt); err != nil {
if err := rl.ReplaceEvent(ctx, evt); err != nil {
switch err {
case eventstore.ErrDupEvent:
return true, nil
+265
View File
@@ -0,0 +1,265 @@
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 -2
View File
@@ -167,14 +167,14 @@ func (rl *Relay) StartExpirationManager(
}
go rl.expirationManager.start(rl.ctx)
rl.Info.AddSupportedNIP(40)
rl.Info.AddSupportedNIP("40")
}
func (rl *Relay) DisableExpirationManager() {
rl.expirationManager.stop()
rl.expirationManager = nil
idx := slices.Index(rl.Info.SupportedNIPs, 40)
idx := slices.Index(rl.Info.SupportedNIPs, "40")
if idx != -1 {
rl.Info.SupportedNIPs[idx] = rl.Info.SupportedNIPs[len(rl.Info.SupportedNIPs)-1]
rl.Info.SupportedNIPs = rl.Info.SupportedNIPs[0 : len(rl.Info.SupportedNIPs)-1]
+1 -1
View File
@@ -31,7 +31,7 @@ func New(rl *khatru.Relay, repositoryDir string) *GraspServer {
},
}
rl.Info.AddSupportedNIP(34)
rl.Info.AddSupportedNIP("34")
rl.Info.SupportedGrasps = append(rl.Info.SupportedGrasps, "GRASP-01")
base := rl.Router()
+4 -2
View File
@@ -43,8 +43,8 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
})
relayPathMatches := true
if rl.ServiceURL != "" {
p, err := url.Parse(rl.ServiceURL)
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
p, err := url.Parse(serviceURL)
if err == nil {
relayPathMatches = strings.TrimSuffix(r.URL.Path, "/") == strings.TrimSuffix(p.Path, "/")
}
@@ -290,6 +290,8 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ws.WriteJSON(resp)
case *nostr.ReqEnvelope:
rl.removeListenerId(ws, env.SubscriptionID)
eose := sync.WaitGroup{}
eose.Add(len(env.Filters))
+141 -64
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"iter"
"sync"
"fiatjaf.com/lib/set"
"fiatjaf.com/nostr"
@@ -31,19 +32,27 @@ type subscription struct {
}
type dispatcher struct {
serial int
subscriptions *xsync.MapOf[int, subscription]
byAuthor map[nostr.PubKey]set.Set[int]
byKind map[nostr.Kind]set.Set[int]
fallback set.Set[int]
serial int
subscriptions *xsync.MapOf[int, subscription]
byAuthor *xsync.MapOf[nostr.PubKey, set.Set[int]]
byKind *xsync.MapOf[nostr.Kind, set.Set[int]]
fallbackTags set.Set[int]
fallbackNothing set.Set[int]
}
var setPool = sync.Pool{
New: func() any {
return set.NewEmptySliceSetReusing[int](make([]int, 0, 10))
},
}
func newDispatcher() dispatcher {
return dispatcher{
subscriptions: xsync.NewMapOf[int, subscription](),
byAuthor: make(map[nostr.PubKey]set.Set[int]),
byKind: make(map[nostr.Kind]set.Set[int]),
fallback: set.NewSliceSet[int](),
subscriptions: xsync.NewMapOf[int, subscription](),
byAuthor: xsync.NewMapOf[nostr.PubKey, set.Set[int]](),
byKind: xsync.NewMapOf[nostr.Kind, set.Set[int]](),
fallbackTags: setPool.Get().(set.Set[int]),
fallbackNothing: setPool.Get().(set.Set[int]),
}
}
@@ -57,93 +66,128 @@ func (d *dispatcher) addSubscription(sub subscription) int {
if sub.filter.Authors != nil {
indexed = true
for _, author := range sub.filter.Authors {
s, ok := d.byAuthor[author]
if !ok {
s = set.NewSliceSet[int]()
d.byAuthor[author] = s
}
s.Add(ssid)
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
if !loaded {
s = setPool.Get().(set.Set[int])
}
s.Add(ssid)
return s, false
})
}
}
if sub.filter.Kinds != nil {
indexed = true
for _, kind := range sub.filter.Kinds {
s, ok := d.byKind[kind]
if !ok {
s = set.NewSliceSet[int]()
d.byKind[kind] = s
}
s.Add(ssid)
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
if !loaded {
s = setPool.Get().(set.Set[int])
}
s.Add(ssid)
return s, false
})
}
}
if !indexed {
d.fallback.Add(ssid)
if sub.filter.Tags != nil {
d.fallbackTags.Add(ssid)
} else {
d.fallbackNothing.Add(ssid)
}
}
return ssid
}
func (d *dispatcher) removeSubscription(ssid int) {
sub, ok := d.subscriptions.LoadAndDelete(ssid)
if !ok {
return
}
func (d *dispatcher) removeSubscription(ssid int) nostr.Filter {
var filter nostr.Filter
indexed := false
if sub.filter.Authors != nil {
indexed = true
for _, author := range sub.filter.Authors {
s, ok := d.byAuthor[author]
if !ok {
return
}
s.Remove(ssid)
if s.Len() == 0 {
delete(d.byAuthor, author)
d.subscriptions.Compute(ssid, func(sub subscription, loaded bool) (subscription, bool) {
indexed := false
filter = sub.filter
if sub.filter.Authors != nil {
indexed = true
for _, author := range sub.filter.Authors {
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
if !loaded {
return s, true
}
s.Remove(ssid)
delete := s.Len() == 0
if delete {
setPool.Put(s)
}
return s, delete
})
}
}
}
if sub.filter.Kinds != nil {
indexed = true
for _, kind := range sub.filter.Kinds {
s, ok := d.byKind[kind]
if !ok {
return
}
s.Remove(ssid)
if s.Len() == 0 {
delete(d.byKind, kind)
if sub.filter.Kinds != nil {
indexed = true
for _, kind := range sub.filter.Kinds {
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
if !loaded {
return s, true
}
s.Remove(ssid)
delete := s.Len() == 0
if delete {
setPool.Put(s)
}
return s, delete
})
}
}
}
if !indexed {
d.fallback.Remove(ssid)
}
if !indexed {
if sub.filter.Tags != nil {
d.fallbackTags.Remove(ssid)
} else {
d.fallbackNothing.Remove(ssid)
}
}
return sub, true
})
return filter
}
func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
return func(yield func(subscription) bool) {
authorSubs, hasAuthorSubs := d.byAuthor[event.PubKey]
kindSubs, hasKindSubs := d.byKind[event.Kind]
authorSubs, hasAuthorSubs := d.byAuthor.Load(event.PubKey)
kindSubs, hasKindSubs := d.byKind.Load(event.Kind)
if hasAuthorSubs && hasKindSubs {
for _, ssid := range authorSubs.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if kindSubs.Has(ssid) {
if kindSubs.Has(ssid) || sub.filter.Kinds == nil {
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if !yield(sub) {
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
}
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if !yield(sub) {
return
}
}
}
} else if hasAuthorSubs {
for _, ssid := range authorSubs.Slice() {
@@ -177,10 +221,21 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
}
}
for _, ssid := range d.fallback.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if len(event.Tags) > 0 {
for _, ssid := range d.fallbackTags.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if !yield(sub) {
return
}
}
}
}
for _, ssid := range d.fallbackNothing.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if filterMatchesTimestampConstraints(sub.filter, event) {
if !yield(sub) {
return
}
@@ -190,7 +245,7 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
}
//go:inline
func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.Event) bool {
func filterMatchesTimestampConstraints(filter nostr.Filter, event nostr.Event) bool {
if filter.Since != 0 && event.CreatedAt < filter.Since {
return false
}
@@ -199,6 +254,15 @@ func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.E
return false
}
return true
}
//go:inline
func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.Event) bool {
if !filterMatchesTimestampConstraints(filter, event) {
return false
}
for f, v := range filter.Tags {
if !event.Tags.ContainsAny(f, v) {
return false
@@ -247,6 +311,10 @@ func (rl *Relay) addListener(
cancel: cancel,
sid: id,
})
if rl.OnListenerAdded != nil {
rl.OnListenerAdded(ws, ssid, id, filter)
}
}
}
@@ -261,7 +329,12 @@ func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
for _, spec := range specs {
if spec.sid == id {
spec.cancel(ErrSubscriptionClosedByClient)
rl.dispatcher.removeSubscription(spec.ssid)
filter := rl.dispatcher.removeSubscription(spec.ssid)
if rl.OnListenerRemoved != nil {
rl.OnListenerRemoved(ws, spec.ssid, id, filter)
}
continue
}
kept = append(kept, spec)
@@ -276,7 +349,11 @@ func (rl *Relay) removeClientAndListeners(ws *WebSocket) {
if specs, ok := rl.clients[ws]; ok {
for _, spec := range specs {
// no need to cancel contexts since they inherit from the main connection context
rl.dispatcher.removeSubscription(spec.ssid)
filter := rl.dispatcher.removeSubscription(spec.ssid)
if rl.OnListenerRemoved != nil {
rl.OnListenerRemoved(ws, spec.ssid, spec.sid, filter)
}
}
}
delete(rl.clients, ws)
+1 -5
View File
@@ -1,7 +1,6 @@
package khatru
import (
"math/rand"
"testing"
"fiatjaf.com/nostr"
@@ -125,10 +124,7 @@ func FuzzRandomListenerIdRemoving(f *testing.F) {
}
require.Equal(t, len(subs)+extra, ssidCount)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
for _, wsidToRemove := range moduloOrder(subs, int(utw+ubs+ualf+ualef)) {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
+16 -8
View File
@@ -1,7 +1,6 @@
package khatru
import (
"math/rand"
"strings"
"testing"
@@ -23,6 +22,18 @@ func idFromSeq(seq int, min, max int) string {
return result.String()
}
func moduloOrder[T any](items []T, seed int) []T {
remaining := append([]T(nil), items...)
ordered := make([]T, 0, len(items))
for len(remaining) > 0 {
idx := seed % len(remaining)
ordered = append(ordered, remaining[idx])
remaining = append(remaining[:idx], remaining[idx+1:]...)
seed++
}
return ordered
}
func TestListenerSetupAndRemoveOnce(t *testing.T) {
rl := NewRelay()
@@ -321,7 +332,7 @@ func TestRandomListenerClientRemoving(t *testing.T) {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
if (i+j)%2 == 0 {
l++
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
}
@@ -374,12 +385,12 @@ func TestRandomListenerIdRemoving(t *testing.T) {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
if (i+j)%2 == 0 {
id := w + ":" + idFromSeqLower(j)
rl.addListener(ws, id, f, cancel)
subs = append(subs, wsid{ws, id})
if rand.Intn(5) < 1 {
if (i+j)%5 == 0 {
rl.addListener(ws, id, f, cancel)
extra++
}
@@ -394,10 +405,7 @@ func TestRandomListenerIdRemoving(t *testing.T) {
}
require.Equal(t, len(subs)+extra, ssidCount)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
for _, wsidToRemove := range moduloOrder(subs, 20) {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
+3 -3
View File
@@ -12,13 +12,13 @@ func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
info := *rl.Info
if nil != rl.DeleteEvent {
info.AddSupportedNIP(9)
info.AddSupportedNIP("9")
}
if nil != rl.Count {
info.AddSupportedNIP(45)
info.AddSupportedNIP("45")
}
if rl.Negentropy {
info.AddSupportedNIP(77)
info.AddSupportedNIP("77")
}
// resolve relative icon and banner URLs against base URL
+18
View File
@@ -21,8 +21,10 @@ type RelayManagementAPI struct {
BanPubKey func(ctx context.Context, pubkey nostr.PubKey, reason string) error
ListBannedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
UnbanPubKey func(ctx context.Context, pubkey nostr.PubKey, reason string) error
AllowPubKey func(ctx context.Context, pubkey nostr.PubKey, reason string) error
ListAllowedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
UnallowPubKey func(ctx context.Context, pubkey nostr.PubKey, reason string) error
ListEventsNeedingModeration func(ctx context.Context) ([]nip86.IDReason, error)
AllowEvent func(ctx context.Context, id nostr.ID, reason string) error
BanEvent func(ctx context.Context, id nostr.ID, reason string) error
@@ -168,6 +170,14 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
} else {
resp.Result = result
}
case nip86.UnbanPubKey:
if rl.ManagementAPI.UnbanPubKey == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.UnbanPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.AllowPubKey:
if rl.ManagementAPI.AllowPubKey == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
@@ -184,6 +194,14 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
} else {
resp.Result = result
}
case nip86.UnallowPubKey:
if rl.ManagementAPI.UnallowPubKey == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.UnallowPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.BanEvent:
if rl.ManagementAPI.BanEvent == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
+110 -6
View File
@@ -2,6 +2,8 @@ package khatru
import (
"context"
"encoding/base64"
"encoding/binary"
"iter"
"log"
"net/http"
@@ -9,6 +11,7 @@ import (
"strconv"
"strings"
"time"
"unsafe"
"fiatjaf.com/lib/channelmutex"
"fiatjaf.com/nostr"
@@ -30,7 +33,7 @@ func NewRelay() *Relay {
Info: &nip11.RelayInformationDocument{
Software: "https://pkg.go.dev/fiatjaf.com/nostr/khatru",
Version: "n/a",
SupportedNIPs: []any{1, 11, 42, 70, 86},
SupportedNIPs: []string{"1", "11", "42", "70", "86"},
},
upgrader: websocket.Upgrader{
@@ -68,7 +71,7 @@ type Relay struct {
// hooks that will be called at various times
OnEvent func(ctx context.Context, event nostr.Event) (reject bool, msg string)
StoreEvent func(ctx context.Context, event nostr.Event) error
ReplaceEvent func(ctx context.Context, event nostr.Event) ([]nostr.Event, error)
ReplaceEvent func(ctx context.Context, event nostr.Event) error
DeleteEvent func(ctx context.Context, id nostr.ID) error
OnEventSaved func(ctx context.Context, event nostr.Event)
OnEventDeleted func(ctx context.Context, deleted nostr.Event)
@@ -81,6 +84,8 @@ type Relay struct {
RejectConnection func(r *http.Request) bool
OnConnect func(ctx context.Context)
OnDisconnect func(ctx context.Context)
OnListenerAdded func(ws *WebSocket, ssid int, id string, filter nostr.Filter)
OnListenerRemoved func(ws *WebSocket, ssid int, id string, filter nostr.Filter)
OverwriteRelayInformation func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
PreventBroadcast func(ws *WebSocket, filter nostr.Filter, event nostr.Event) bool
@@ -145,8 +150,9 @@ func (rl *Relay) UseEventstore(store eventstore.Store, maxQueryLimit int) {
rl.StoreEvent = func(ctx context.Context, event nostr.Event) error {
return store.SaveEvent(event)
}
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) ([]nostr.Event, error) {
return store.ReplaceEvent(event)
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) error {
_, err := store.ReplaceEvent(event)
return err
}
rl.DeleteEvent = func(ctx context.Context, id nostr.ID) error {
return store.DeleteEvent(id)
@@ -165,8 +171,8 @@ func (rl *Relay) UseEventstore(store eventstore.Store, maxQueryLimit int) {
}
func (rl *Relay) getBaseURL(r *http.Request) string {
if rl.ServiceURL != "" {
return rl.ServiceURL
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
return serviceURL
}
host := r.Header.Get("X-Forwarded-Host")
@@ -191,6 +197,14 @@ func (rl *Relay) getBaseURL(r *http.Request) string {
return proto + "://" + host + r.URL.Path
}
func (rl *Relay) getServiceURL(r *http.Request) string {
if serviceURL, ok := r.Context().Value(serviceURLOverrideKey).(string); ok {
return serviceURL
}
return rl.ServiceURL
}
// Stats returns the current number of connected clients and open listeners.
func (rl *Relay) Stats() (clients, listeners int) {
rl.clientsMutex.Lock()
@@ -203,6 +217,89 @@ func (rl *Relay) Stats() (clients, listeners int) {
return len(rl.clients), listeners
}
type ClientInfo struct {
ID string
IP string
UserAgent string
Origin string
Authenticated []nostr.PubKey
SubscriptionCount int
}
type SubscriptionInfo struct {
ID string
Filter nostr.Filter
}
type ClientSnapshot struct {
ClientInfo
Subscriptions []SubscriptionInfo
}
func (rl *Relay) ListClients() []ClientInfo {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
clients := make([]ClientInfo, 0, len(rl.clients))
for ws, specs := range rl.clients {
clients = append(clients, ClientInfo{
ID: ws.GetID(),
IP: GetIPFromRequest(ws.Request),
UserAgent: ws.Request.UserAgent(),
Origin: ws.Request.Header.Get("Origin"),
Authenticated: ws.AuthedPublicKeys,
SubscriptionCount: len(specs),
})
}
return clients
}
func (rl *Relay) GetClientSnapshot(id string) (ClientSnapshot, bool) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
ptrn, err := base64.RawURLEncoding.DecodeString(id)
if err != nil {
return ClientSnapshot{}, false
}
ptr := binary.LittleEndian.Uint64(ptrn)
// DANGEROUS:
// don't try to do anything with this `ws` object before we confirm it exists by checking the rl.clients map
ws := (*WebSocket)(unsafe.Pointer(uintptr(ptr)))
specs, ok := rl.clients[ws]
if !ok {
return ClientSnapshot{}, false
}
details := ClientSnapshot{
ClientInfo: ClientInfo{
ID: id,
IP: GetIPFromRequest(ws.Request),
UserAgent: ws.Request.UserAgent(),
Origin: ws.Request.Header.Get("Origin"),
Authenticated: ws.AuthedPublicKeys,
SubscriptionCount: len(specs),
},
Subscriptions: make([]SubscriptionInfo, 0, len(specs)),
}
for _, spec := range specs {
filter := nostr.Filter{}
if sub, ok := rl.dispatcher.subscriptions.Load(spec.ssid); ok {
filter = sub.filter
}
details.Subscriptions = append(details.Subscriptions, SubscriptionInfo{
ID: spec.sid,
Filter: filter,
})
}
return details, true
}
func (rl *Relay) Router() *http.ServeMux {
return rl.serveMux
}
@@ -210,3 +307,10 @@ func (rl *Relay) Router() *http.ServeMux {
func (rl *Relay) SetRouter(mux *http.ServeMux) {
rl.serveMux = mux
}
func (rl *Relay) WithServiceURL(serviceURL string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), serviceURLOverrideKey, serviceURL)
rl.ServeHTTP(w, r.WithContext(ctx))
})
}
+5 -6
View File
@@ -3,7 +3,6 @@ package khatru
import (
"context"
"math"
"math/rand/v2"
"net/http/httptest"
"testing"
"time"
@@ -14,13 +13,15 @@ import (
)
func FuzzReplaceableEvents(f *testing.F) {
f.Add(uint(1), uint(2))
f.Add(1, 1, uint(2))
f.Fuzz(func(t *testing.T, seed uint, nevents uint) {
f.Fuzz(func(t *testing.T, seed int, advance int, nevents uint) {
if nevents == 0 {
return
}
state := fuzzState{value: seed, advance: advance}
relay := NewRelay()
store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"}
store.Init()
@@ -67,12 +68,10 @@ func FuzzReplaceableEvents(f *testing.F) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
rnd := rand.New(rand.NewPCG(uint64(seed), 0))
newest := nostr.Timestamp(0)
for range nevents {
evt := createEvent(sk1, 0, `{"name":"blblbl"}`, nil)
evt.CreatedAt = nostr.Timestamp(rnd.Int64() % math.MaxUint32)
evt.CreatedAt = nostr.Timestamp(state.next(math.MaxUint32))
evt.Sign(sk1)
err = client1.Publish(ctx, evt)
if err != nil {
+54
View File
@@ -2,6 +2,9 @@ package khatru
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
@@ -9,9 +12,60 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/nip11"
"github.com/stretchr/testify/require"
)
func TestWithServiceURL(t *testing.T) {
relay := NewRelay()
relay.Info.Icon = "icon.png"
relay.Info.Banner = "banner.png"
relay.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
io.WriteString(w, "fallback")
})
handlerA := relay.WithServiceURL("https://a.example/relay")
handlerB := relay.WithServiceURL("https://b.example/relay")
t.Run("uses override for nip11 base url", func(t *testing.T) {
for _, tc := range []struct {
name string
handler http.Handler
expectedBase string
}{
{name: "first interface", handler: handlerA, expectedBase: "https://a.example/relay"},
{name: "second interface", handler: handlerB, expectedBase: "https://b.example/relay"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://internal/relay", nil)
req.Header.Set("Accept", "application/nostr+json")
rr := httptest.NewRecorder()
tc.handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
var info nip11.RelayInformationDocument
require.NoError(t, json.NewDecoder(rr.Body).Decode(&info))
require.Equal(t, tc.expectedBase+"/icon.png", info.Icon)
require.Equal(t, tc.expectedBase+"/banner.png", info.Banner)
})
}
})
t.Run("uses override for relay path matching", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://internal/not-relay", nil)
req.Header.Set("Accept", "application/nostr+json")
rr := httptest.NewRecorder()
handlerA.ServeHTTP(rr, req)
require.Equal(t, http.StatusAccepted, rr.Code)
require.Equal(t, "fallback", rr.Body.String())
})
}
func TestBasicRelayFunctionality(t *testing.T) {
// setup relay with in-memory store
relay := NewRelay()
@@ -0,0 +1,5 @@
go test fuzz v1
int(-180)
int(92)
byte('{')
byte('\n')
@@ -0,0 +1,5 @@
go test fuzz v1
int(140)
int(-52)
byte('"')
byte('h')
@@ -1,3 +1,4 @@
go test fuzz v1
uint(25)
int(25)
int(1)
uint(223)
+8
View File
@@ -11,6 +11,7 @@ const (
subscriptionIdKey
nip86HeaderAuthKey
internalCallKey
serviceURLOverrideKey
)
func RequestAuth(ctx context.Context) {
@@ -73,6 +74,13 @@ func IsAuthed(ctx context.Context, pubkey nostr.PubKey) bool {
return false
}
// ForceSetAuthed modifies the context to insert a custom authed public key.
// It can be used in testing or other rare scenarios for making requests as if a given public key
// was authenticated when in fact it didn't perform any of the authentication rituals.
func ForceSetAuthed(ctx context.Context, pubkey nostr.PubKey) context.Context {
return context.WithValue(ctx, nip86HeaderAuthKey, pubkey)
}
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
// or expiration request.
func IsInternalCall(ctx context.Context) bool {
+10
View File
@@ -2,9 +2,12 @@ package khatru
import (
"context"
"encoding/base64"
"encoding/binary"
"fmt"
"net/http"
"sync"
"unsafe"
"fiatjaf.com/nostr"
"github.com/fasthttp/websocket"
@@ -31,6 +34,13 @@ type WebSocket struct {
negentropySessions *xsync.MapOf[string, *NegentropySession]
}
func (ws *WebSocket) GetID() string {
ptr := uintptr(unsafe.Pointer(ws))
var id [8]byte
binary.LittleEndian.PutUint64(id[:], uint64(ptr))
return base64.RawURLEncoding.EncodeToString(id[:])
}
func (ws *WebSocket) WriteJSON(any any) error {
if ws == nil {
return fmt.Errorf("connection doesn't exist")
+6
View File
@@ -274,6 +274,10 @@ func (kind Kind) Name() string {
return "VideoViewEvent"
case KindCommunityDefinition:
return "CommunityDefinition"
case KindNsiteRoot:
return "NsiteRoot"
case KindNsiteNamed:
return "NsiteNamed"
}
return "unknown"
}
@@ -360,6 +364,7 @@ const (
KindGoodWikiAuthorList Kind = 10101
KindGoodWikiRelayList Kind = 10102
KindNWCWalletInfo Kind = 13194
KindNsiteRoot Kind = 15128
KindLightningPubRPC Kind = 21000
KindClientAuthentication Kind = 22242
KindNWCWalletRequest Kind = 23194
@@ -394,6 +399,7 @@ const (
KindDraftClassifiedListing Kind = 30403
KindRepositoryAnnouncement Kind = 30617
KindRepositoryState Kind = 30618
KindNsiteNamed Kind = 35128
KindSimpleGroupMetadata Kind = 39000
KindSimpleGroupAdmins Kind = 39001
KindSimpleGroupMembers Kind = 39002
+18 -18
View File
@@ -9,30 +9,30 @@ import (
func TestAddSupportedNIP(t *testing.T) {
info := RelayInformationDocument{}
info.AddSupportedNIP(12)
info.AddSupportedNIP(12)
info.AddSupportedNIP(13)
info.AddSupportedNIP(1)
info.AddSupportedNIP(12)
info.AddSupportedNIP(44)
info.AddSupportedNIP(2)
info.AddSupportedNIP(13)
info.AddSupportedNIP(2)
info.AddSupportedNIP(13)
info.AddSupportedNIP(0)
info.AddSupportedNIP(17)
info.AddSupportedNIP(19)
info.AddSupportedNIP(1)
info.AddSupportedNIP(18)
info.AddSupportedNIP("12")
info.AddSupportedNIP("12")
info.AddSupportedNIP("13")
info.AddSupportedNIP("1")
info.AddSupportedNIP("12")
info.AddSupportedNIP("44")
info.AddSupportedNIP("2")
info.AddSupportedNIP("13")
info.AddSupportedNIP("2")
info.AddSupportedNIP("13")
info.AddSupportedNIP("0")
info.AddSupportedNIP("17")
info.AddSupportedNIP("19")
info.AddSupportedNIP("1")
info.AddSupportedNIP("18")
assert.Contains(t, info.SupportedNIPs, 0, 1, 2, 12, 13, 17, 18, 19, 44)
assert.Contains(t, info.SupportedNIPs, "0", "1", "2", "12", "13", "17", "18", "19", "44")
}
func TestAddSupportedNIPs(t *testing.T) {
info := RelayInformationDocument{}
info.AddSupportedNIPs([]int{0, 1, 2, 12, 13, 17, 18, 19, 44})
info.AddSupportedNIPs([]int{"0", "1", "2", "12", "13", "17", "18", "19", "44"})
assert.Contains(t, info.SupportedNIPs, 0, 1, 2, 12, 13, 17, 18, 19, 44)
assert.Contains(t, info.SupportedNIPs, "0", "1", "2", "12", "13", "17", "18", "19", "44")
}
func TestFetch(t *testing.T) {
+5 -5
View File
@@ -14,7 +14,7 @@ type RelayInformationDocument struct {
PubKey *nostr.PubKey `json:"pubkey,omitempty"`
Self *nostr.PubKey `json:"self,omitempty"`
Contact string `json:"contact,omitempty"`
SupportedNIPs []any `json:"supported_nips,omitempty"`
SupportedNIPs []string `json:"supported_nips,omitempty"`
Software string `json:"software,omitempty"`
Version string `json:"version,omitempty"`
@@ -33,16 +33,16 @@ type RelayInformationDocument struct {
SupportedGrasps []string `json:"supported_grasps,omitempty"`
}
func (info *RelayInformationDocument) AddSupportedNIP(number int) {
idx := slices.IndexFunc(info.SupportedNIPs, func(n any) bool { return n == number })
func (info *RelayInformationDocument) AddSupportedNIP(nip string) {
idx := slices.IndexFunc(info.SupportedNIPs, func(n string) bool { return n == nip })
if idx != -1 {
return
}
info.SupportedNIPs = append(info.SupportedNIPs, number)
info.SupportedNIPs = append(info.SupportedNIPs, nip)
}
func (info *RelayInformationDocument) AddSupportedNIPs(numbers []int) {
func (info *RelayInformationDocument) AddSupportedNIPs(numbers []string) {
for _, n := range numbers {
info.AddSupportedNIP(n)
}
+38
View File
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,149 @@
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
@@ -0,0 +1,237 @@
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
}
+52
View File
@@ -32,6 +32,24 @@ func DecodeRequest(req Request) (MethodParams, error) {
return BanPubKey{pk, reason}, nil
case "listbannedpubkeys":
return ListBannedPubKeys{}, nil
case "unbanpubkey":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
pkh, ok := req.Params[0].(string)
if !ok {
return nil, fmt.Errorf("missing pubkey param for '%s'", req.Method)
}
pk, err := nostr.PubKeyFromHex(pkh)
if err != nil {
return nil, fmt.Errorf("invalid pubkey param for '%s'", req.Method)
}
var reason string
if len(req.Params) >= 2 {
reason, _ = req.Params[1].(string)
}
return UnbanPubKey{pk, reason}, nil
case "allowpubkey":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
@@ -52,6 +70,24 @@ func DecodeRequest(req Request) (MethodParams, error) {
return AllowPubKey{pk, reason}, nil
case "listallowedpubkeys":
return ListAllowedPubKeys{}, nil
case "unallowpubkey":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
pkh, ok := req.Params[0].(string)
if !ok {
return nil, fmt.Errorf("missing pubkey param for '%s'", req.Method)
}
pk, err := nostr.PubKeyFromHex(pkh)
if err != nil {
return nil, fmt.Errorf("invalid pubkey param for '%s'", req.Method)
}
var reason string
if len(req.Params) >= 2 {
reason, _ = req.Params[1].(string)
}
return UnallowPubKey{pk, reason}, nil
case "listeventsneedingmoderation":
return ListEventsNeedingModeration{}, nil
case "allowevent":
@@ -219,8 +255,10 @@ var (
_ MethodParams = (*SupportedMethods)(nil)
_ MethodParams = (*BanPubKey)(nil)
_ MethodParams = (*ListBannedPubKeys)(nil)
_ MethodParams = (*UnbanPubKey)(nil)
_ MethodParams = (*AllowPubKey)(nil)
_ MethodParams = (*ListAllowedPubKeys)(nil)
_ MethodParams = (*UnallowPubKey)(nil)
_ MethodParams = (*ListEventsNeedingModeration)(nil)
_ MethodParams = (*AllowEvent)(nil)
_ MethodParams = (*BanEvent)(nil)
@@ -256,6 +294,13 @@ type ListBannedPubKeys struct{}
func (ListBannedPubKeys) MethodName() string { return "listbannedpubkeys" }
type UnbanPubKey struct {
PubKey nostr.PubKey
Reason string
}
func (UnbanPubKey) MethodName() string { return "unbanpubkey" }
type AllowPubKey struct {
PubKey nostr.PubKey
Reason string
@@ -267,6 +312,13 @@ type ListAllowedPubKeys struct{}
func (ListAllowedPubKeys) MethodName() string { return "listallowedpubkeys" }
type UnallowPubKey struct {
PubKey nostr.PubKey
Reason string
}
func (UnallowPubKey) MethodName() string { return "unallowpubkey" }
type ListEventsNeedingModeration struct{}
func (ListEventsNeedingModeration) MethodName() string { return "listeventsneedingmoderation" }
+9 -12
View File
@@ -2,6 +2,7 @@ package blossom
import (
"context"
"encoding/hex"
"fmt"
"io"
"net/http"
@@ -11,19 +12,17 @@ import (
)
// Download downloads a file from the media server by its hash
func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) {
if !nostr.IsValid32ByteHex(hash) {
return nil, fmt.Errorf("%s is not a valid 32-byte hex string", hash)
}
func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) {
hhash := hex.EncodeToString(hash[:])
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
evt.Tags = append(evt.Tags, nostr.Tag{"x", hash})
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
})
req.Header.Add("Authorization", authHeader)
@@ -41,19 +40,17 @@ func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) {
}
// DownloadToFile downloads a file from the media server and saves it to the specified path
func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error {
if !nostr.IsValid32ByteHex(hash) {
return fmt.Errorf("%s is not a valid 32-byte hex string", hash)
}
func (c *Client) DownloadToFile(ctx context.Context, hash [32]byte, filePath string) error {
hhash := hex.EncodeToString(hash[:])
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hhash, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
authHeader := c.authorizationHeader(ctx, func(evt *nostr.Event) {
evt.Tags = append(evt.Tags, nostr.Tag{"t", "get"})
evt.Tags = append(evt.Tags, nostr.Tag{"x", hash})
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
})
req.Header.Add("Authorization", authHeader)
+3 -1
View File
@@ -313,7 +313,9 @@ func easyjson33014d6eEncodeFiatjafComNostr2(out *jwriter.Writer, in EntityPointe
out.RawString(prefix)
out.Uint(uint(in.Kind))
}
{
if in.Identifier == "" && in.Kind.IsReplaceable() {
// this is expected, no identifiers in replaceable events, so don't print
// but we do print in case there is an identifier, incorrectly, to assist debug
const prefix string = ",\"identifier\":"
out.RawString(prefix)
out.String(in.Identifier)
+26 -38
View File
@@ -24,13 +24,26 @@ type Pool struct {
Relays *xsync.MapOf[string, *Relay]
Context context.Context
authRequiredHandler func(context.Context, *Event) error
cancel context.CancelCauseFunc
cancel context.CancelCauseFunc
EventMiddleware func(RelayEvent)
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
// with the "auth-required:" prefix, only once for each relay
AuthRequiredHandler func(context.Context, *Event) error
// EventMiddleware is a function that will be called with all events received.
EventMiddleware func(RelayEvent)
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
DuplicateMiddleware func(relay string, id ID)
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
RelayOptions RelayOptions
// AuthorKindQueryMiddleware is a function that will be called with every combination of
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
// contains a pubkey and a kind).
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
// RelayOptions are any options that should be passed to Relays instantiated by this pool
RelayOptions RelayOptions
// custom things not often used
penaltyBox *xsync.MapOf[string, [2]float64]
@@ -60,31 +73,6 @@ func NewPool() *Pool {
}
}
type PoolOptions struct {
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
// with the "auth-required:" prefix, only once for each relay
AuthRequiredHandler func(context.Context, *Event) error
// PenaltyBox just sets the penalty box mechanism so relays that fail to connect
// or that disconnect will be ignored for a while and we won't attempt to connect again.
PenaltyBox bool
// EventMiddleware is a function that will be called with all events received.
EventMiddleware func(RelayEvent)
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
DuplicateMiddleware func(relay string, id ID)
// AuthorKindQueryMiddleware is a function that will be called with every combination of
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
// contains a pubkey and a kind).
AuthorKindQueryMiddleware func(relay string, pubkey PubKey, kind Kind)
// RelayOptions are any options that should be passed to Relays instantiated by this pool
RelayOptions RelayOptions
}
func (pool *Pool) StartPenaltyBox() {
pool.penaltyBox = xsync.NewMapOf[string, [2]float64]()
@@ -207,9 +195,9 @@ func (pool *Pool) PublishMany(ctx context.Context, urls []string, evt Event) cha
if err := relay.Publish(ctx, evt); err == nil {
// success with no auth required
ch <- PublishResult{nil, url, relay}
} else if strings.HasPrefix(err.Error(), "msg: auth-required:") && pool.authRequiredHandler != nil {
} else if strings.HasPrefix(err.Error(), "msg: auth-required:") && pool.AuthRequiredHandler != nil {
// try to authenticate if we can
if authErr := relay.Auth(ctx, pool.authRequiredHandler); authErr == nil {
if authErr := relay.Auth(ctx, pool.AuthRequiredHandler); authErr == nil {
if err := relay.Publish(ctx, evt); err == nil {
// success after auth
ch <- PublishResult{nil, url, relay}
@@ -394,9 +382,9 @@ func (pool *Pool) FetchManyReplaceable(
case <-sub.EndOfStoredEvents:
return
case reason := <-sub.ClosedReason:
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
// relay is requesting auth. if we can we will perform auth and try again
err := relay.Auth(ctx, pool.authRequiredHandler)
err := relay.Auth(ctx, pool.AuthRequiredHandler)
if err == nil {
hasAuthed = true // so we don't keep doing AUTH again and again
goto subscribe
@@ -570,9 +558,9 @@ func (pool *Pool) subMany(
}
}
case reason := <-sub.ClosedReason:
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
// relay is requesting auth. if we can we will perform auth and try again
err := relay.Auth(ctx, pool.authRequiredHandler)
err := relay.Auth(ctx, pool.AuthRequiredHandler)
if err == nil {
hasAuthed = true // so we don't keep doing AUTH again and again
if closedChan != nil {
@@ -677,9 +665,9 @@ func (pool *Pool) subManyEose(
case <-sub.EndOfStoredEvents:
return
case reason := <-sub.ClosedReason:
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
// relay is requesting auth. if we can we will perform auth and try again
err := relay.Auth(ctx, pool.authRequiredHandler)
err := relay.Auth(ctx, pool.AuthRequiredHandler)
if err == nil {
hasAuthed = true // so we don't keep doing AUTH again and again
if closedChan != nil {
+4
View File
@@ -93,6 +93,7 @@ func NewRelay(ctx context.Context, url string, opts RelayOptions) *Relay {
noticeHandler: opts.NoticeHandler,
authHandler: opts.AuthHandler,
closed: &atomic.Bool{},
AssumeValid: opts.AssumeValid,
}
go func() {
@@ -147,6 +148,9 @@ type RelayOptions struct {
// RequestHeader sets the HTTP request header of the websocket preflight request
RequestHeader http.Header
// AssumeValid disables signature verification for events received from this relay
AssumeValid bool
}
// String just returns the relay URL.
-4
View File
@@ -246,10 +246,6 @@ func (rte RequiredTagError) Error() string {
}
func (v *Validator) ValidateEvent(evt nostr.Event) error {
if !isTrimmed(evt.Content) {
return ContentError{ErrDanglingSpace}
}
if sch, ok := v.Schema.Kinds[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
if validator, ok := v.TypeValidators[sch.Content.Type]; ok {
if err := validator(evt.Content, &sch.Content); err != nil {
+34
View File
@@ -0,0 +1,34 @@
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,6 +61,8 @@ type System struct {
MediaFollowListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
goodWikiAuthorListCacheOnce sync.Once
GoodWikiAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
blossomServerListCacheOnce sync.Once
BlossomServerListCache cache.Cache32[GenericList[string, BlossomURL]]
gitAuthorListCacheOnce sync.Once
GitAuthorListCache cache.Cache32[GenericList[nostr.PubKey, ProfileRef]]
relaySetsCacheOnce sync.Once
+13 -35
View File
@@ -19,18 +19,17 @@ type wotCall struct {
id uint64 // basically the pubkey we're targeting here
mutex sync.Mutex
resultbacks []chan WotXorFilter // all callers waiting for results
errorbacks []chan error // all callers waiting for errors
done chan struct{} // this is closed when this call is fully resolved and deleted
}
const wotCallsSize = 8
const wotCallsSize = 16
var (
wotCallsMutex sync.Mutex
wotCallsInPlace [wotCallsSize]*wotCall
)
func (sys *System) LoadWoTFilter(ctx context.Context, pubkey nostr.PubKey) (WotXorFilter, error) {
func (sys *System) LoadWoTFilter(ctx context.Context, pubkey nostr.PubKey) WotXorFilter {
id := PubKeyToShid(pubkey)
pos := int(id % wotCallsSize)
@@ -42,7 +41,6 @@ start:
wc = &wotCall{
id: id,
resultbacks: make([]chan WotXorFilter, 0),
errorbacks: make([]chan error, 0),
done: make(chan struct{}),
}
wotCallsInPlace[pos] = wc
@@ -54,17 +52,13 @@ start:
wc.mutex.Lock()
if wc.id == id {
// there is already a call for this exact pubkey ongoing, so we just wait
// there is already a call for this exact pubkey ongoing, so we just wait and copy the results
resch := make(chan WotXorFilter)
errch := make(chan error)
wc.resultbacks = append(wc.resultbacks, resch)
wc.errorbacks = append(wc.errorbacks, errch)
wc.mutex.Unlock()
select {
case res := <-resch:
return res, nil
case err := <-errch:
return WotXorFilter{}, err
return res
}
} else {
wc.mutex.Unlock()
@@ -76,18 +70,11 @@ start:
actualcall:
var res WotXorFilter
m, err := sys.loadWoT(ctx, pubkey)
if err != nil {
wc.mutex.Lock()
for _, ch := range wc.errorbacks {
ch <- err
}
} else {
res = makeWoTFilter(m)
wc.mutex.Lock()
for _, ch := range wc.resultbacks {
ch <- res
}
m := sys.loadWoT(ctx, pubkey)
res = makeWoTFilter(m)
wc.mutex.Lock()
for _, ch := range wc.resultbacks {
ch <- res
}
wotCallsMutex.Lock()
@@ -96,23 +83,17 @@ actualcall:
close(wc.done)
wotCallsMutex.Unlock()
return res, err
return res
}
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) (chan nostr.PubKey, error) {
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) chan nostr.PubKey {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(45)
res := make(chan nostr.PubKey)
// process follow lists
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
for _, f := range sys.FetchFollowList(ctx, pubkey).Items {
wg.Add(1)
g.Go(func() error {
res <- f.Pubkey
@@ -123,20 +104,17 @@ func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) (chan nostr
for _, f2 := range ff {
res <- f2.Pubkey
}
wg.Done()
return nil
})
}
wg.Done()
}()
go func() {
wg.Wait()
g.Wait()
close(res)
}()
return res, nil
return res
}
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
+4 -7
View File
@@ -3,8 +3,6 @@
package nostr
import (
"crypto/sha256"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -34,8 +32,8 @@ func (evt Event) VerifySignature() bool {
sig := schnorr.NewSignature(&r, &s)
// check signature
hash := sha256.Sum256(evt.Serialize())
return sig.Verify(hash[:], pubkey)
evt.SetID()
return sig.Verify(evt.ID[:], pubkey)
}
// Sign signs an event with a given privateKey.
@@ -52,13 +50,12 @@ func (evt *Event) Sign(secretKey [32]byte) error {
pkBytes := pk.SerializeCompressed()[1:]
evt.PubKey = PubKey(pkBytes)
h := sha256.Sum256(evt.Serialize())
sig, err := schnorr.Sign(sk, h[:], schnorr.FastSign())
evt.SetID()
sig, err := schnorr.Sign(sk, evt.ID[:], schnorr.FastSign())
if err != nil {
return err
}
evt.ID = h
sigb := sig.Serialize()
evt.Sig = [64]byte(sigb)
+4 -7
View File
@@ -25,7 +25,6 @@ import "C"
import (
"crypto/rand"
"crypto/sha256"
"errors"
"unsafe"
@@ -33,14 +32,14 @@ import (
)
func (evt Event) VerifySignature() bool {
msg := sha256.Sum256(evt.Serialize())
evt.SetID()
var xonly C.secp256k1_xonly_pubkey
if C.secp256k1_xonly_pubkey_parse(globalSecp256k1Context, &xonly, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0]))) != 1 {
return false
}
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&msg[0])), 32, &xonly)
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), 32, &xonly)
return res == 1
}
@@ -59,16 +58,14 @@ func (evt *Event) Sign(secretKey [32]byte, signOpts ...schnorr.SignOption) error
C.secp256k1_keypair_xonly_pub(globalSecp256k1Context, &xonly, nil, &keypair)
C.secp256k1_xonly_pubkey_serialize(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0])), &xonly)
h := sha256.Sum256(evt.Serialize())
evt.SetID()
var random [32]byte
rand.Read(random[:])
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 {
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 {
return errors.New("failed to sign message")
}
evt.ID = h
return nil
}
+16
View File
@@ -124,3 +124,19 @@ func (tags Tags) ContainsAny(tagName string, values []string) bool {
return false
}
func (tags Tags) Eq(other Tags) bool {
if len(tags) != len(other) {
return false
}
for i, tag := range tags {
otherTag := other[i]
if !slices.Equal(tag, otherTag) {
return false
}
}
return true
}
+500
View File
File diff suppressed because one or more lines are too long