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
69 changed files with 735 additions and 3753 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)
}
-2
View File
@@ -74,8 +74,6 @@ func easyjsonDecodeEvent(in *jlexer.Lexer, out *Event) {
if len(b) == 128 { if len(b) == 128 {
xhex.Decode(out.Sig[:], b) xhex.Decode(out.Sig[:], b)
} }
default:
in.SkipRecursive()
} }
in.WantComma() in.WantComma()
} }
+16 -46
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{},
}
evt.Sign(Generate())
for i := 0; i < b.N; i++ { b.Run("naïve", func(b *testing.B) {
for _, evt := range events { for b.Loop() {
if !evt.VerifySignature() { _ = evt.GetID() == evt.ID
b.Fatal("signature verification failed")
}
} }
})
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
} }
-185
View File
@@ -2,13 +2,10 @@ package bleve
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/lmdb" "fiatjaf.com/nostr/eventstore/lmdb"
"fiatjaf.com/nostr/eventstore/slicestore"
"github.com/pemistahl/lingua-go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -24,7 +21,6 @@ func TestBleveFlow(t *testing.T) {
bl := BleveBackend{ bl := BleveBackend{
Path: "/tmp/blevetest-bleve", Path: "/tmp/blevetest-bleve",
RawEventStore: bb, RawEventStore: bb,
Languages: []lingua.Language{lingua.English},
} }
err := bl.Init() err := bl.Init()
require.NoError(t, err, "init") require.NoError(t, err, "init")
@@ -78,184 +74,3 @@ func TestBleveFlow(t *testing.T) {
assert.Equal(t, 1, n) assert.Equal(t, 1, n)
} }
} }
func TestSearch(t *testing.T) {
tempDir, err := os.MkdirTemp("", "test_search_pyramid")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
db := &slicestore.SliceStore{}
db.Init()
index := &BleveBackend{
Path: filepath.Join(tempDir, "test_index"),
RawEventStore: db,
Languages: []lingua.Language{
lingua.English,
lingua.Portuguese,
lingua.Italian,
},
}
err = index.Init()
require.NoError(t, err)
defer index.Close()
pirateEvents := []nostr.Event{
{
ID: nostr.MustIDFromHex("0000000000000000000000000000000000000000000000000000000000000001"),
PubKey: nostr.MustPubKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001"),
CreatedAt: nostr.Timestamp(1609459200),
Kind: 1,
Content: "Ahoy mateys! I've discovered a treasure chest filled with gold doubloons and silver pieces buried beneath the old palm tree on Skull Island. The secret map shows an X marks the spot where the legendary pirate Blackbeard hid his most valuable plunder. The chest contains rubies, emeralds, and ancient coins from sunken Spanish galleons. https://www.youtube.com/watch?v=enTAromEeHo&t=88s",
Tags: nil,
},
{
ID: nostr.MustIDFromHex("0000000000000000000000000000000000000000000000000000000000000002"),
PubKey: nostr.MustPubKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001"),
CreatedAt: nostr.Timestamp(1609545600),
Kind: 1111,
Content: "The treasure map I found reveals the location of Captain Morgan's lost gold mine deep in the Caribbean waters. Following the ancient compass directions leads to a hidden cave filled with golden artifacts, jeweled swords, and the crown jewels of forgotten kingdoms. The secret passage is guarded by mysterious symbols only known to the brotherhood of the sea. https://www.youtube.com/watch?v=yBtyNIqZios",
Tags: nil,
},
{
ID: nostr.MustIDFromHex("0000000000000000000000000000000000000000000000000000000000000003"),
PubKey: nostr.MustPubKeyFromHex("0000000000000000000000000000000000000000000000000000000000000003"),
CreatedAt: nostr.Timestamp(1609632000),
Kind: 1,
Content: "Legends speak of the Emerald City of the Lost Pirates, a mythical place where streets are paved with gold and buildings adorned with precious gems. The secret entrance can only be found during a full moon when the tides reveal a hidden path across the coral reefs. Ancient scrolls tell of guardians protecting treasure vaults containing the world's most valuable gems.",
Tags: nil,
},
{
ID: nostr.MustIDFromHex("0000000000000000000000000000000000000000000000000000000000000004"),
PubKey: nostr.MustPubKeyFromHex("0000000000000000000000000000000000000000000000000000000000000004"),
CreatedAt: nostr.Timestamp(1609545601),
Kind: 1111,
Content: "Bom dia seus piratas melequentos, onde está esse bendito tesouro? nostr:nprofile1qqsv6jemsnaq925ddfqjhwm3du3k0zk7dnj2ksk2k4hcfkf80mzf56spz9mhxue69uhkzcnpvdshgefwvdhk6tmjzyj",
Tags: nil,
},
{
ID: nostr.MustIDFromHex("0000000000000000000000000000000000000000000000000000000000000005"),
PubKey: nostr.MustPubKeyFromHex("0000000000000000000000000000000000000000000000000000000000000005"),
CreatedAt: nostr.Timestamp(1609545602),
Kind: 30023,
Content: "I pirati dei Caraibi del XVII e XVIII secolo sono diventati leggendari per la loro ricerca di tesori. Questi avventurieri del mare saccheggiavano navi cariche d'oro, argento e pietre preziose provenienti dalle colonie spagnole del Nuovo Mondo.\n\nSecondo la leggenda, molti pirati seppellivano i loro tesori su isole remote, creando mappe segrete con la famosa \"X\" che segnava il punto. Capitani famosi come Barbanera, Capitan Kidd e Henry Morgan sono entrati nell'immaginario collettivo come custodi di ricchezze nascoste.\n\nAnche se la maggior parte dei tesori dei pirati sono probabilmente solo miti, alcuni sono stati davvero ritrovati. Il fascino di questi bottini nascosti continua ad ispirare storie, film e cacciatori di tesori ancora oggi.",
Tags: nil,
},
}
for _, event := range pirateEvents {
err := db.SaveEvent(event)
require.NoError(t, err)
err = index.SaveEvent(event)
require.NoError(t, err)
}
testCases := []struct {
name string
filter nostr.Filter
expected int
}{
{
name: "search for 'gold'",
filter: nostr.Filter{
Search: "gold",
},
expected: 3, // all events mention gold
},
{
name: "search for 'treasure'",
filter: nostr.Filter{
Search: "treasure",
},
expected: 3, // all events mention treasure
},
{
name: "search for 'emerald' together with 'astronomical'",
filter: nostr.Filter{
Search: "astronomical emeralds",
},
expected: 0, // no events mention emeralds together with astronomical
},
{
name: "search for 'secret map'",
filter: nostr.Filter{
Search: "\"secret map\"",
},
expected: 1, // only one event mentions secret map
},
{
name: "search with kind filter",
filter: nostr.Filter{
Search: "gold",
Kinds: []nostr.Kind{1},
},
expected: 2, // only two events are kind 1
},
{
name: "search in portuguese",
filter: nostr.Filter{
Search: "melequento",
},
expected: 1,
},
{
name: "search with exact match",
filter: nostr.Filter{
Search: "\"the secret entrance can only be found during a full moon\"",
},
expected: 1,
},
{
name: "search with OR across languages",
filter: nostr.Filter{
Search: "melequento OR matey",
},
expected: 2,
},
{
name: "search with exact reference found in the text",
filter: nostr.Filter{
Search: "tesouro nostr:nprofile1qqsv6jemsnaq925ddfqjhwm3du3k0zk7dnj2ksk2k4hcfkf80mzf56spzpmhxue69uhkyctwv9hxztnrdaksmfp5mw", // this is the same pubkey from above, but it's a different nprofile
},
expected: 1,
},
{
name: "search for URL",
filter: nostr.Filter{
Search: "https://www.youtube.com/watch?v=yBtyNIqZios treasure",
},
expected: 1,
},
{
name: "search for host/domain of URL",
filter: nostr.Filter{
Search: "www.youtube.com",
},
expected: 2,
},
{
name: "mentioning the author should include their notes in the result",
filter: nostr.Filter{
Search: " nostr:npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqshp52w2",
},
expected: 2,
},
{
name: "mentioning the author should include their notes in the result",
filter: nostr.Filter{
Search: "found gold? nostr:npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqshp52w2",
},
expected: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var count int
for range index.QueryEvents(tc.filter, 10) {
count++
}
require.Equal(t, tc.expected, count)
})
}
}
+4 -14
View File
@@ -202,12 +202,9 @@ func (b *BleveBackend) Init() error {
} }
b.index = index b.index = index
if len(b.Languages) >= 2 {
b.detector = lingua.NewLanguageDetectorBuilder(). b.detector = lingua.NewLanguageDetectorBuilder().
FromLanguages(b.Languages...). FromLanguages(b.Languages...).
Build() Build()
}
return nil return nil
} }
@@ -251,14 +248,13 @@ 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] { switch tag[0] {
case "comment", "name", "title", "about", "description": case "comment":
evt.Content += "\n\n" + tag[1] evt.Content += "\n\n" + tag[1]
case "e": case "e":
if ptr, err := nostr.EventPointerFromTag(tag); err == nil { if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
@@ -272,6 +268,7 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
references = append(references, tag[1]) references = append(references, tag[1])
} }
} }
}
doc := map[string]any{ doc := map[string]any{
labelKindField: strconv.Itoa(int(evt.Kind)), labelKindField: strconv.Itoa(int(evt.Kind)),
@@ -294,17 +291,10 @@ 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 len(b.Languages) == 1 {
lang = b.Languages[0]
} else {
var ok bool
lang, ok = b.detector.DetectLanguageOf(indexableContent)
if !ok { if !ok {
lang = lingua.English lang = lingua.English
} }
}
var analyzerLangCode string var analyzerLangCode string
switch lang { switch lang {
+1 -1
View File
@@ -19,7 +19,7 @@ import (
var ( var (
db eventstore.Store db eventstore.Store
end = func() {} end func()
) )
var app = &cli.Command{ var app = &cli.Command{
-199
View File
@@ -2,32 +2,14 @@ package mmm
import ( import (
"cmp" "cmp"
"encoding/binary"
"fmt" "fmt"
"iter"
"runtime"
"slices" "slices"
"syscall"
"unsafe"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/codec/betterbinary"
"github.com/PowerDNS/lmdb-go/lmdb" "github.com/PowerDNS/lmdb-go/lmdb"
) )
const LARGE_FREERANGE = 142 const LARGE_FREERANGE = 142
// AllFreeRanges returns an iterator of (start_pos, size) of all free ranges, in positional order.
func (b *MultiMmapManager) AllFreeRanges() iter.Seq2[uint64, uint32] {
return func(yield func(uint64, uint32) bool) {
for _, pos := range b.freeRangesAll {
if !yield(pos.start, pos.size) {
return
}
}
}
}
func (b *MultiMmapManager) gatherFreeRanges(txn *lmdb.Txn) error { func (b *MultiMmapManager) gatherFreeRanges(txn *lmdb.Txn) error {
cursor, err := txn.OpenCursor(b.indexId) cursor, err := txn.OpenCursor(b.indexId)
if err != nil { if err != nil {
@@ -168,184 +150,3 @@ func (b *MultiMmapManager) mergeNewFreeRange(newFreeRange position) {
} }
} }
} }
func (b *MultiMmapManager) Defragment(n int) error {
for range min(n, len(b.freeRangesAll)-1) {
if err := b.DefragmentOne(); err != nil {
return err
}
}
return nil
}
// Defragment a single free range
func (b *MultiMmapManager) DefragmentOne() error {
if b.ReadOnly {
return ReadOnly
}
b.writeMutex.Lock()
defer b.writeMutex.Unlock()
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if len(b.freeRangesAll) < 2 {
return nil
}
mmmtxn, err := b.lmdbEnv.BeginTxn(nil, 0)
if err != nil {
return fmt.Errorf("failed to begin mmm transaction: %w", err)
}
defer mmmtxn.Abort()
type layerTxn struct {
il *IndexingLayer
txn *lmdb.Txn
}
layerTxns := make(map[uint16]*layerTxn)
defer func() {
for _, lt := range layerTxns {
lt.txn.Abort()
}
}()
// will put stuff into the first free range
fr := b.freeRangesAll[0]
// where the free range ends, the events start (any number of them)
eventsStart := fr.start + uint64(fr.size)
eventsEnd := b.freeRangesAll[1].start // and they end when the next free range starts
fmt.Println("# defrag", fr, eventsStart, eventsEnd)
c := uint64(0) // this tracks our relative position inside the events section
for (eventsStart + c) < eventsEnd {
var evt nostr.Event
if err := betterbinary.Unmarshal(b.mmapf[(eventsStart+c):eventsEnd], &evt); err != nil {
id := betterbinary.GetID(b.mmapf[(eventsStart + c):eventsEnd])
return fmt.Errorf("failed to read event (%x) from mmap: %w", id[:], err)
}
// now that we have an event we'll update its pos on the id index and on every layer:
oldVal, err := mmmtxn.Get(b.indexId, evt.ID[0:8])
if err != nil {
return fmt.Errorf("failed to read val (%x) from index: %w", evt.ID[:], err)
}
// current position
pos := positionFromBytes(oldVal[0:12])
// new position (from the beginning of the free range before + relative position)
fmt.Println(" moving event", evt.ID, "from", pos)
pos.start = fr.start + uint64(c)
// update this cursor
c += uint64(pos.size)
fmt.Println(" to", pos, "...", c, "layers:", oldVal[12:])
// prepare and save id index
newVal := make([]byte, len(oldVal))
writeBytesFromPosition(newVal, pos)
copy(newVal[12:], oldVal[12:])
if err := mmmtxn.Put(b.indexId, evt.ID[0:8], newVal, 0); err != nil {
return fmt.Errorf("failed to write new pos to id index: %w", err)
}
for s := 12; s < len(oldVal); s += 2 {
layer := binary.BigEndian.Uint16(oldVal[s : s+2])
lt, ok := layerTxns[layer]
if !ok {
il := b.layers.ByID(layer)
if il == nil {
fmt.Println(b.layers)
panic(fmt.Errorf("missing layer %d", layer))
}
txn, err := il.lmdbEnv.BeginTxn(nil, 0)
if err != nil {
return fmt.Errorf("failed to begin layer txn for layer %d: %w", il.id, err)
}
txn.RawRead = true
lt = &layerTxn{il: il, txn: txn}
layerTxns[il.id] = lt
}
fmt.Println(" layer", lt.il.id)
for k := range lt.il.getIndexKeysForEvent(evt) {
fmt.Println(" index", k.dbi, k.key)
if err := lt.txn.Del(k.dbi, k.key, oldVal[0:12]); err != nil {
return fmt.Errorf("failed to delete old index entry for %x: %w", evt.ID[:], err)
}
if err := lt.txn.Put(k.dbi, k.key, newVal[0:12], 0); err != nil {
return fmt.Errorf("failed to insert new index entry for %x: %w", evt.ID[:], err)
}
}
}
}
// now that we have updated all the pointers, just copy all the bytes between the two free ranges
copy(b.mmapf[fr.start:], b.mmapf[fr.start+uint64(fr.size):eventsEnd])
// delete this free range if it's one of the big ones
if fr.isLarge() {
for l, lfr := range b.freeRangesLarge {
if lfr.start == fr.start {
fmt.Println(" deleting large fr", l, lfr)
b.freeRangesLarge[l] = b.freeRangesLarge[len(b.freeRangesLarge)-1]
b.freeRangesLarge = b.freeRangesLarge[0 : len(b.freeRangesLarge)-1]
break
}
}
}
// now we have some space left at the end of this events section that is a free range
remainingSpaceStart := fr.start + c
// it must be merged with the next free range
updated := position{
start: remainingSpaceStart,
size: b.freeRangesAll[1].size + uint32(eventsEnd) - uint32(remainingSpaceStart),
}
nextWasLarge := b.freeRangesAll[1].isLarge()
fmt.Println(" updating next", updated)
b.freeRangesAll[1] = updated
if nextWasLarge {
for l, lfr := range b.freeRangesLarge {
if lfr.start == eventsEnd {
fmt.Println("it is large:", l, lfr, "(now", updated, ")")
b.freeRangesLarge[l] = updated
break
}
}
} else if updated.isLarge() {
// if it wasn't large but now is, add it to the list of large free ranges
fmt.Println(" a new large fr was created", updated)
b.freeRangesLarge = append(b.freeRangesLarge, updated)
}
// msync
_, _, errno := syscall.Syscall(syscall.SYS_MSYNC,
uintptr(unsafe.Pointer(&b.mmapf[0])), uintptr(len(b.mmapf)), syscall.MS_SYNC)
if errno != 0 {
return fmt.Errorf("msync failed: %w", syscall.Errno(errno))
}
// commit transactions
if err := mmmtxn.Commit(); err != nil {
return fmt.Errorf("failed to commit mmm transaction: %w", err)
}
for lid, lt := range layerTxns {
if err := lt.txn.Commit(); err != nil {
return fmt.Errorf("failed to commit layer %d transaction: %w", lid, err)
}
}
// delete the first free range
b.freeRangesAll = slices.Delete(b.freeRangesAll, 0, 1)
return nil
}
+1 -303
View File
@@ -1,11 +1,8 @@
package mmm package mmm
import ( import (
"cmp"
"fmt"
"math/rand/v2" "math/rand/v2"
"os" "os"
"slices"
"strings" "strings"
"testing" "testing"
@@ -128,100 +125,6 @@ func FuzzFreeRanges(f *testing.F) {
}) })
} }
func TestDefragment(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mmm-defrag-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
mmmm := &MultiMmapManager{Dir: tmpDir}
err = mmmm.Init()
require.NoError(t, err)
defer mmmm.Close()
il, err := mmmm.EnsureLayer("a")
require.NoError(t, err)
defer il.Close()
sk := nostr.MustSecretKeyFromHex("945e01e37662430162121b804d3645a86d97df9d256917d86735d0eb219393eb")
const nevents = 30
var stored [nevents]nostr.Event
for i := range nevents {
evt := nostr.Event{
CreatedAt: nostr.Timestamp(i),
Kind: nostr.KindTextNote,
Tags: nostr.Tags{},
Content: fmt.Sprintf("============= event %d ============= "+strings.Repeat("+", 23), i),
}
evt.Sign(sk)
err := il.SaveEvent(evt)
require.NoError(t, err)
stored[i] = evt
}
toDelete := []int{0, 5, 10, 15, 20}
var remaining []nostr.Event
for i, evt := range stored {
if slices.Contains(toDelete, i) {
err := il.DeleteEvent(evt.ID)
require.NoError(t, err)
} else {
remaining = append(remaining, evt)
}
}
require.Len(t, toDelete, len(mmmm.freeRangesAll))
err = mmmm.Defragment(2)
require.NoError(t, err)
require.Len(t, mmmm.freeRangesAll, 3)
require.Len(t, remaining, nevents-len(toDelete))
// all remaining events still accessible with correct content via GetByID
for _, evt := range remaining {
gotEvt, layers := mmmm.GetByID(evt.ID)
require.NotNil(t, gotEvt, "event %s should exist after defrag", evt.ID)
require.NotEmpty(t, layers, "event %s should have layers after defrag", evt.ID)
require.Equal(t, evt.Content, gotEvt.Content, "event %s content should match after defrag", evt.ID)
// also accessible via a query
require.Equal(t, il, layers[0])
}
evts := slices.Collect(il.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.KindTextNote}}, 100))
require.Len(t, evts, nevents-len(toDelete))
// free range invariants hold after defrag
verifyFreeRangesInvariants(t, mmmm)
// no overlapping positions after defrag
mmmm.lmdbEnv.View(func(txn *lmdb.Txn) error {
cursor, err := txn.OpenCursor(mmmm.indexId)
require.NoError(t, err)
defer cursor.Close()
var allPositions []position
for _, val, err := cursor.Get(nil, nil, lmdb.First); err == nil; _, val, err = cursor.Get(nil, val, lmdb.Next) {
pos := positionFromBytes(val[0:12])
allPositions = append(allPositions, pos)
}
slices.SortFunc(allPositions, func(a, b position) int {
return cmp.Compare(a.start, b.start)
})
var lastEnd uint64
for _, pos := range allPositions {
if pos.start < lastEnd {
t.Fatalf("event overlap after defrag: %d < %d", pos.start, lastEnd)
}
lastEnd = pos.start + uint64(pos.size)
}
return nil
})
}
func countUsableFreeRanges(t *testing.T, mmmm *MultiMmapManager) (count int, space int) { func countUsableFreeRanges(t *testing.T, mmmm *MultiMmapManager) (count int, space int) {
for _, fr := range mmmm.freeRangesAll { for _, fr := range mmmm.freeRangesAll {
if fr.size >= LARGE_FREERANGE { if fr.size >= LARGE_FREERANGE {
@@ -239,13 +142,7 @@ func verifyFreeRangesInvariants(t *testing.T, mmmm *MultiMmapManager) {
all := mmmm.freeRangesAll all := mmmm.freeRangesAll
large := mmmm.freeRangesLarge large := mmmm.freeRangesLarge
require.True(t, slices.IsSortedFunc(all, func(a, b position) int {
return cmp.Compare(a.start, b.start)
}), "free ranges aren't sorted by start position")
for _, l := range large { for _, l := range large {
require.True(t, l.isLarge())
found := false found := false
for _, a := range all { for _, a := range all {
if l.start == a.start && l.size == a.size { if l.start == a.start && l.size == a.size {
@@ -260,7 +157,7 @@ func verifyFreeRangesInvariants(t *testing.T, mmmm *MultiMmapManager) {
require.Greater(t, all[i].start, all[i-1].start, "all ranges should be sorted by start") require.Greater(t, all[i].start, all[i-1].start, "all ranges should be sorted by start")
} }
for i, fr := range all { for i := range all {
for j := i + 1; j < len(all); j++ { for j := i + 1; j < len(all); j++ {
end1 := all[i].start + uint64(all[i].size) end1 := all[i].start + uint64(all[i].size)
end2 := all[j].start + uint64(all[j].size) end2 := all[j].start + uint64(all[j].size)
@@ -268,17 +165,6 @@ func verifyFreeRangesInvariants(t *testing.T, mmmm *MultiMmapManager) {
(all[j].start >= all[i].start && all[j].start < end1), (all[j].start >= all[i].start && all[j].start < end1),
"ranges %v and %v overlap", all[i], all[j]) "ranges %v and %v overlap", all[i], all[j])
} }
foundInLarge := false
for _, l := range large {
if l.start == fr.start {
foundInLarge = true
break
}
}
if !foundInLarge {
require.False(t, fr.isLarge())
}
} }
mmmm.lmdbEnv.View(func(txn *lmdb.Txn) error { mmmm.lmdbEnv.View(func(txn *lmdb.Txn) error {
@@ -290,191 +176,3 @@ func verifyFreeRangesInvariants(t *testing.T, mmmm *MultiMmapManager) {
return nil return nil
}) })
} }
func FuzzDefragment(f *testing.F) {
f.Add(0)
f.Fuzz(func(t *testing.T, seed int) {
tmpDir, err := os.MkdirTemp("", "mmm-defrag-fuzz-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
logger := zerolog.Nop()
rnd := rand.New(rand.NewPCG(uint64(seed), 0))
mmmm := &MultiMmapManager{
Dir: tmpDir,
Logger: &logger,
}
err = mmmm.Init()
require.NoError(t, err)
defer mmmm.Close()
layerNames := []string{"a", "b", "c"}
var layers []*IndexingLayer
for _, name := range layerNames {
il, err := mmmm.EnsureLayer(name)
require.NoError(t, err)
defer il.Close()
layers = append(layers, il)
}
type indexedEvent struct {
evt nostr.Event
tag string
}
layerEvents := make([][]indexedEvent, len(layers))
sk := nostr.MustSecretKeyFromHex("945e01e37662430162121b804d3645a86d97df9d256917d86735d0eb219393eb")
pk := sk.Public()
totalEvents := rnd.IntN(500)
tChoices := []string{"foo", "bar", "banana"}
var written int
for written < totalEvents {
n := rnd.IntN(50) + 1
if n > totalEvents-written {
n = totalEvents - written
}
for i := 0; i < n; i++ {
sizeParam := rnd.IntN(2000)
content := strings.Repeat("z", sizeParam)
chosenTag := tChoices[rnd.IntN(3)]
evt := nostr.Event{
CreatedAt: nostr.Timestamp(rnd.Uint32()),
Kind: nostr.KindTextNote,
Content: content,
Tags: nostr.Tags{{"t", chosenTag}},
}
evt.Sign(sk)
nLayers := rnd.IntN(len(layers)) + 1
perm := rnd.Perm(len(layers))
for pi := 0; pi < nLayers; pi++ {
li := perm[pi]
err := layers[li].SaveEvent(evt)
require.NoError(t, err)
layerEvents[li] = append(layerEvents[li], indexedEvent{evt, chosenTag})
}
written++
}
if n > 0 {
totalRemaining := 0
for _, levts := range layerEvents {
totalRemaining += len(levts)
}
if totalRemaining > 0 {
m := rnd.IntN(n)
if m > totalRemaining {
m = totalRemaining
}
for i := 0; i < m; i++ {
var nonEmpty []int
for li, levts := range layerEvents {
if len(levts) > 0 {
nonEmpty = append(nonEmpty, li)
}
}
if len(nonEmpty) == 0 {
break
}
li := nonEmpty[rnd.IntN(len(nonEmpty))]
idx := rnd.IntN(len(layerEvents[li]))
evtInfo := layerEvents[li][idx]
err := layers[li].DeleteEvent(evtInfo.evt.ID)
require.NoError(t, err)
layerEvents[li] = append(layerEvents[li][:idx], layerEvents[li][idx+1:]...)
}
}
}
if n > 0 {
o := rnd.IntN(n)
for i := 0; i < o; i++ {
if len(mmmm.freeRangesAll) > 1 {
param := rnd.IntN(len(mmmm.freeRangesAll))
err := mmmm.Defragment(param)
require.NoError(t, err)
}
verifyFreeRangesInvariants(t, mmmm)
}
}
}
// query each layer
for li, il := range layers {
levts := layerEvents[li]
// query by author
evts := slices.Collect(il.QueryEvents(nostr.Filter{Authors: []nostr.PubKey{pk}}, 10000))
require.Equal(t, len(levts), len(evts))
// query by author and kind
evts = slices.Collect(il.QueryEvents(nostr.Filter{Authors: []nostr.PubKey{pk}, Kinds: []nostr.Kind{nostr.KindTextNote}}, 10000))
require.Equal(t, len(levts), len(evts))
// query by "t" tag
for _, tagVal := range tChoices {
expected := 0
for _, ie := range levts {
if ie.tag == tagVal {
expected++
}
}
evts = slices.Collect(il.QueryEvents(nostr.Filter{Tags: nostr.TagMap{"t": []string{tagVal}}}, 10000))
require.Equal(t, expected, len(evts))
}
// query with no parameters
allEvts := slices.Collect(il.QueryEvents(nostr.Filter{}, 10000))
require.Equal(t, len(levts), len(allEvts))
}
// build union of all events across all layers
allEventSet := make(map[string]nostr.Event)
for _, levts := range layerEvents {
for _, ie := range levts {
allEventSet[ie.evt.ID.String()] = ie.evt
}
}
// all events still accessible via GetByID
for _, evt := range allEventSet {
gotEvt, eventLayers := mmmm.GetByID(evt.ID)
require.NotNil(t, gotEvt)
require.NotEmpty(t, eventLayers)
require.Equal(t, evt.Content, gotEvt.Content)
}
verifyFreeRangesInvariants(t, mmmm)
mmmm.lmdbEnv.View(func(txn *lmdb.Txn) error {
cursor, err := txn.OpenCursor(mmmm.indexId)
require.NoError(t, err)
defer cursor.Close()
var allPositions []position
for _, val, err := cursor.Get(nil, nil, lmdb.First); err == nil; _, val, err = cursor.Get(nil, val, lmdb.Next) {
pos := positionFromBytes(val[0:12])
allPositions = append(allPositions, pos)
}
slices.SortFunc(allPositions, func(a, b position) int {
return cmp.Compare(a.start, b.start)
})
var lastEnd uint64
for _, pos := range allPositions {
if pos.start < lastEnd {
t.Fatalf("event overlap after defrag: %d < %d", pos.start, lastEnd)
}
lastEnd = pos.start + uint64(pos.size)
}
return nil
})
})
}
-5
View File
@@ -142,11 +142,6 @@ func FuzzTest(f *testing.F) {
mmmm.Rescan() mmmm.Rescan()
} }
// perform random defrags -- shouldn't break the database
if rnd.UintN(3) == 1 {
mmmm.Defragment(len(deleted) / 3)
}
for id, deletedlayers := range deleted { for id, deletedlayers := range deleted {
evt, foundlayers := mmmm.GetByID(id) evt, foundlayers := mmmm.GetByID(id)
-1
View File
@@ -341,7 +341,6 @@ func (b *MultiMmapManager) removeAllReferencesFromLayer(txn *lmdb.Txn, layerId u
return nil return nil
} }
//go:inline
func (b *MultiMmapManager) loadEvent(pos position, eventReceiver *nostr.Event) error { func (b *MultiMmapManager) loadEvent(pos position, eventReceiver *nostr.Event) error {
return betterbinary.Unmarshal(b.mmapf[pos.start:pos.start+uint64(pos.size)], eventReceiver) return betterbinary.Unmarshal(b.mmapf[pos.start:pos.start+uint64(pos.size)], eventReceiver)
} }
+5 -2
View File
@@ -17,6 +17,11 @@ func (poss positions) find(start uint64) (idx int) {
return idx return idx
} }
func (poss positions) del(start uint64) positions {
idx := poss.find(start)
return slices.Delete(poss, idx, idx+1)
}
func (poss positions) String() string { func (poss positions) String() string {
str := strings.Builder{} str := strings.Builder{}
str.Grow(10 + 20*len(poss)) str.Grow(10 + 20*len(poss))
@@ -41,7 +46,6 @@ func (pos position) isLarge() bool {
return pos.size >= LARGE_FREERANGE return pos.size >= LARGE_FREERANGE
} }
//go:inline
func positionFromBytes(posb []byte) position { func positionFromBytes(posb []byte) position {
return position{ return position{
size: binary.BigEndian.Uint32(posb[0:4]), size: binary.BigEndian.Uint32(posb[0:4]),
@@ -49,7 +53,6 @@ func positionFromBytes(posb []byte) position {
} }
} }
//go:inline
func writeBytesFromPosition(out []byte, pos position) { func writeBytesFromPosition(out []byte, pos position) {
binary.BigEndian.PutUint32(out[0:4], pos.size) binary.BigEndian.PutUint32(out[0:4], pos.size)
binary.BigEndian.PutUint64(out[4:12], pos.start) binary.BigEndian.PutUint64(out[4:12], pos.start)
+2 -4
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
"slices"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -120,8 +119,7 @@ func (b *MultiMmapManager) storeOn(
b.freeRangesLarge = b.freeRangesLarge[0 : len(b.freeRangesLarge)-1] b.freeRangesLarge = b.freeRangesLarge[0 : len(b.freeRangesLarge)-1]
// also delete it from b.freeRangesAll // also delete it from b.freeRangesAll
idx := b.freeRangesAll.find(fr.start) b.freeRangesAll = b.freeRangesAll.del(fr.start)
b.freeRangesAll = slices.Delete(b.freeRangesAll, idx, idx+1)
} else { } else {
// otherwise modify it in place // otherwise modify it in place
newFreeRange := position{ newFreeRange := position{
@@ -157,7 +155,7 @@ func (b *MultiMmapManager) storeOn(
} }
// write to the mmap // write to the mmap
if err := betterbinary.Marshal(evt, b.mmapf[pos.start:pos.start+uint64(pos.size)]); err != nil { if err := betterbinary.Marshal(evt, b.mmapf[pos.start:]); err != nil {
return false, fmt.Errorf("error marshaling to %d: %w", pos.start, err) return false, fmt.Errorf("error marshaling to %d: %w", pos.start, err)
} }
-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,2 +0,0 @@
go test fuzz v1
int(-360)
@@ -1,2 +0,0 @@
go test fuzz v1
int(-17)
@@ -1,5 +0,0 @@
go test fuzz v1
int(46)
uint(84)
uint(55)
uint(5)
+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
-3
View File
@@ -13,9 +13,6 @@ type BlobIndex interface {
List(ctx context.Context, pubkey nostr.PubKey) iter.Seq[blossom.BlobDescriptor] List(ctx context.Context, pubkey nostr.PubKey) iter.Seq[blossom.BlobDescriptor]
Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error) Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error)
Delete(ctx context.Context, sha256 string, pubkey nostr.PubKey) error Delete(ctx context.Context, sha256 string, pubkey nostr.PubKey) error
ListAllBlobs(ctx context.Context) iter.Seq2[nostr.PubKey, blossom.BlobDescriptor]
OwnersForBlob(ctx context.Context, sha256 string) []nostr.PubKey
} }
var ( var (
-24
View File
@@ -59,30 +59,6 @@ func (es EventStoreBlobIndexWrapper) List(ctx context.Context, pubkey nostr.PubK
} }
} }
func (es EventStoreBlobIndexWrapper) ListAllBlobs(ctx context.Context) iter.Seq2[nostr.PubKey, blossom.BlobDescriptor] {
return func(yield func(nostr.PubKey, blossom.BlobDescriptor) bool) {
for evt := range es.Store.QueryEvents(nostr.Filter{
Kinds: []nostr.Kind{24242},
}, 1000) {
bd := es.parseEvent(evt)
if !yield(evt.PubKey, bd) {
return
}
}
}
}
func (es EventStoreBlobIndexWrapper) OwnersForBlob(ctx context.Context, sha256 string) []nostr.PubKey {
var owners []nostr.PubKey
for evt := range es.Store.QueryEvents(nostr.Filter{
Tags: nostr.TagMap{"x": []string{sha256}},
Kinds: []nostr.Kind{24242},
}, 1000) {
owners = append(owners, evt.PubKey)
}
return owners
}
func (es EventStoreBlobIndexWrapper) Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error) { func (es EventStoreBlobIndexWrapper) Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error) {
next, stop := iter.Pull( next, stop := iter.Pull(
es.Store.QueryEvents(nostr.Filter{Tags: nostr.TagMap{"x": []string{sha256}}, Kinds: []nostr.Kind{24242}, Limit: 1}, 1), es.Store.QueryEvents(nostr.Filter{Tags: nostr.TagMap{"x": []string{sha256}}, Kinds: []nostr.Kind{24242}, Limit: 1}, 1),
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -128,7 +129,7 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
hash := sha256.Sum256(b) hash := sha256.Sum256(b)
hhash := nostr.HexEncodeToString(hash[:]) hhash := nostr.HexEncodeToString(hash[:])
mimeType := blossom.GetMIMEType(ext) mimeType := mime.TypeByExtension(ext)
if mimeType == "" { if mimeType == "" {
mimeType = "application/octet-stream" mimeType = "application/octet-stream"
} }
-19
View File
@@ -59,25 +59,6 @@ func (x MemoryBlobIndex) List(ctx context.Context, pubkey nostr.PubKey) iter.Seq
} }
} }
func (x MemoryBlobIndex) ListAllBlobs(ctx context.Context) iter.Seq2[nostr.PubKey, blossom.BlobDescriptor] {
return func(yield func(nostr.PubKey, blossom.BlobDescriptor) bool) {
for _, v := range x.m.Range {
for _, owner := range v.owners {
if !yield(owner, v.blob) {
return
}
}
}
}
}
func (x MemoryBlobIndex) OwnersForBlob(ctx context.Context, sha256 string) []nostr.PubKey {
if val, ok := x.m.Load(sha256); ok {
return val.owners
}
return nil
}
func (x MemoryBlobIndex) Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error) { func (x MemoryBlobIndex) Get(ctx context.Context, sha256 string) (*blossom.BlobDescriptor, error) {
if val, ok := x.m.Load(sha256); ok { if val, ok := x.m.Load(sha256); ok {
return &val.blob, nil return &val.blob, nil
+1 -1
View File
@@ -65,7 +65,7 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt nostr.Event) error
errg, ctx := errgroup.WithContext(ctx) errg, ctx := errgroup.WithContext(ctx)
for target := range rl.QueryStored(ctx, f) { for target := range rl.QueryStored(ctx, f) {
// got the event, now check if the user can delete it // got the event, now check if the user can delete it
if rl.AllowDeleting == nil && target.PubKey == evt.PubKey || rl.AllowDeleting != nil && rl.AllowDeleting(ctx, target, evt) { if target.PubKey == evt.PubKey {
// delete it // delete it
errg.Go(func() error { errg.Go(func() error {
if err := rl.DeleteEvent(ctx, target.ID); err != nil { if err := rl.DeleteEvent(ctx, target.ID); err != 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
}
+9 -10
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, "/")
} }
@@ -227,9 +227,6 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
if writeErr == nil { if writeErr == nil {
// this always returns "blocked: " whenever it returns an error // this always returns "blocked: " whenever it returns an error
writeErr = rl.handleDeleteRequest(ctx, env.Event) writeErr = rl.handleDeleteRequest(ctx, env.Event)
if writeErr == ErrNothingToDelete {
writeErr = nil
}
} }
} else if env.Event.Kind.IsEphemeral() { } else if env.Event.Kind.IsEphemeral() {
// this will also always return a prefixed reason // this will also always return a prefixed reason
@@ -293,7 +290,8 @@ 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.Add(len(env.Filters))
// a context just for the "stored events" request handler // a context just for the "stored events" request handler
reqCtx, cancelReqCtx := context.WithCancelCause(ctx) reqCtx, cancelReqCtx := context.WithCancelCause(ctx)
@@ -303,7 +301,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
// handle each filter separately -- dispatching events as they're loaded from databases // handle each filter separately -- dispatching events as they're loaded from databases
for _, filter := range env.Filters { for _, filter := range env.Filters {
err := rl.handleRequest(reqCtx, env.SubscriptionID, ws, filter) err := rl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
if err != nil { if err != nil {
// fail everything if any filter is rejected // fail everything if any filter is rejected
reason := err.Error() reason := err.Error()
@@ -321,7 +319,11 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
} }
} }
go func() {
// when all events have been loaded from databases and dispatched we can fire the EOSE message
eose.Wait()
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID)) ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
}()
case *nostr.CloseEnvelope: case *nostr.CloseEnvelope:
id := string(*env) id := string(*env)
rl.removeListenerId(ws, id) rl.removeListenerId(ws, id)
@@ -346,9 +348,6 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
} }
ws.authLock.Unlock() ws.authLock.Unlock()
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true}) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true})
if rl.OnAuth != nil {
rl.OnAuth(ctx, pubkey)
}
} else { } else {
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to authenticate: " + err.Error()}) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to authenticate: " + err.Error()})
} }
+40 -117
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"
@@ -34,25 +33,17 @@ 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) s.Add(ssid)
return s, false
})
} }
} }
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) s.Add(ssid)
return s, false
})
} }
} }
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
filter = sub.filter
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 {
return s, true return
} }
s.Remove(ssid) s.Remove(ssid)
if s.Len() == 0 {
delete := s.Len() == 0 delete(d.byAuthor, author)
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,8 +177,7 @@ 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) sub, _ := d.subscriptions.Load(ssid)
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) { if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
@@ -232,20 +187,10 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
} }
} }
} }
for _, ssid := range d.fallbackNothing.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if filterMatchesTimestampConstraints(sub.filter, event) {
if !yield(sub) {
return
}
}
}
}
} }
//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)
} }
-54
View File
@@ -43,12 +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
CreateRole func(ctx context.Context, id string, label string, description string, color int, order int) error
EditRole func(ctx context.Context, id string, label string, description string, color int, order int) error
DeleteRole func(ctx context.Context, id string) error
AssignRole func(ctx context.Context, pubkey nostr.PubKey, roleID string) error
UnassignRole func(ctx context.Context, pubkey nostr.PubKey, roleID string) error
SignEvent func(ctx context.Context, kind nostr.Kind, createdAt nostr.Timestamp, tags nostr.Tags, content string) (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)
} }
@@ -336,54 +330,6 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
} else { } else {
resp.Result = true resp.Result = true
} }
case nip86.CreateRole:
if rl.ManagementAPI.CreateRole == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.CreateRole(ctx, thing.ID, thing.Label, thing.Description, thing.Color, thing.Order); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.EditRole:
if rl.ManagementAPI.EditRole == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.EditRole(ctx, thing.ID, thing.Label, thing.Description, thing.Color, thing.Order); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.DeleteRole:
if rl.ManagementAPI.DeleteRole == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.DeleteRole(ctx, thing.ID); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.AssignRole:
if rl.ManagementAPI.AssignRole == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.AssignRole(ctx, thing.PubKey, thing.RoleID); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.UnassignRole:
if rl.ManagementAPI.UnassignRole == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.UnassignRole(ctx, thing.PubKey, thing.RoleID); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
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.Kind, thing.CreatedAt, thing.Tags, thing.Content); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.ListDisallowedKinds: case nip86.ListDisallowedKinds:
if rl.ManagementAPI.ListDisallowedKinds == nil { if rl.ManagementAPI.ListDisallowedKinds == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName()) resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
+5 -111
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,11 +68,10 @@ 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)
AllowDeleting func(ctx context.Context, target, deletion nostr.Event) bool
OnEphemeralEvent func(ctx context.Context, event nostr.Event) OnEphemeralEvent func(ctx context.Context, event nostr.Event)
OnRequest func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) OnRequest func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
OnCount func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) OnCount func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
@@ -85,9 +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)
OnAuth func(ctx context.Context, pubkey nostr.PubKey)
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
@@ -152,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)
@@ -173,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")
@@ -199,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()
@@ -219,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
} }
@@ -309,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()
+4 -1
View File
@@ -3,12 +3,15 @@ package khatru
import ( import (
"context" "context"
"errors" "errors"
"sync"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45/hyperloglog" "fiatjaf.com/nostr/nip45/hyperloglog"
) )
func (rl *Relay) handleRequest(ctx context.Context, id string, ws *WebSocket, filter nostr.Filter) error { func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) error {
defer eose.Done()
// then check if we'll reject this filter (we apply this after overwriting // then check if we'll reject this filter (we apply this after overwriting
// because we may, for example, remove some things from the incoming filters // because we may, for example, remove some things from the incoming filters
// that we know we don't support, and then if the end result is an empty // that we know we don't support, and then if the end result is an empty
@@ -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")
+24 -178
View File
@@ -38,20 +38,10 @@ func (kind Kind) Name() string {
return "Seal" return "Seal"
case KindDirectMessage: case KindDirectMessage:
return "DirectMessage" return "DirectMessage"
case KindFileMessage:
return "FileMessage"
case KindGenericRepost: case KindGenericRepost:
return "GenericRepost" return "GenericRepost"
case KindReactionToWebsite: case KindReactionToWebsite:
return "ReactionToWebsite" return "ReactionToWebsite"
case KindPhoto:
return "Photo"
case KindNormalVideoEvent:
return "NormalVideoEvent"
case KindShortVideoEvent:
return "ShortVideoEvent"
case KindPublicMessage:
return "PublicMessage"
case KindChannelCreation: case KindChannelCreation:
return "ChannelCreation" return "ChannelCreation"
case KindChannelMetadata: case KindChannelMetadata:
@@ -62,14 +52,12 @@ func (kind Kind) Name() string {
return "ChannelHideMessage" return "ChannelHideMessage"
case KindChannelMuteUser: case KindChannelMuteUser:
return "ChannelMuteUser" return "ChannelMuteUser"
case KindPodcastEpisode:
return "PodcastEpisode"
case KindChess: case KindChess:
return "Chess" return "Chess"
case KindMergeRequests: case KindMergeRequests:
return "MergeRequests" return "MergeRequests"
case KindPollResponse: case KindComment:
return "PollResponse" return "Comment"
case KindBid: case KindBid:
return "Bid" return "Bid"
case KindBidConfirmation: case KindBidConfirmation:
@@ -80,26 +68,10 @@ func (kind Kind) Name() string {
return "GiftWrap" return "GiftWrap"
case KindFileMetadata: case KindFileMetadata:
return "FileMetadata" return "FileMetadata"
case KindPoll:
return "Poll"
case KindComment:
return "Comment"
case KindVoiceMessage:
return "VoiceMessage"
case KindScroll:
return "Scroll"
case KindVoiceMessageComment:
return "VoiceMessageComment"
case KindLiveChatMessage: case KindLiveChatMessage:
return "LiveChatMessage" return "LiveChatMessage"
case KindCodeSnippet:
return "CodeSnippet"
case KindPatch: case KindPatch:
return "Patch" return "Patch"
case KindGitPullRequest:
return "GitPullRequest"
case KindGitPullRequestUpdate:
return "GitPullRequestUpdate"
case KindIssue: case KindIssue:
return "Issue" return "Issue"
case KindReply: case KindReply:
@@ -128,24 +100,10 @@ func (kind Kind) Name() string {
return "TorrentComment" return "TorrentComment"
case KindCoinjoinPool: case KindCoinjoinPool:
return "CoinjoinPool" return "CoinjoinPool"
case KindDecoupledKeyClientAnnouncement:
return "DecoupledKeyClientAnnouncement"
case KindDecoupledEncryptionKeyDistribution:
return "DecoupledEncryptionKeyDistribution"
case KindCommunityPostApproval: case KindCommunityPostApproval:
return "CommunityPostApproval" return "CommunityPostApproval"
case KindJobFeedback: case KindJobFeedback:
return "JobFeedback" return "JobFeedback"
case KindReservedCashuWalletTokens:
return "ReservedCashuWalletTokens"
case KindCashuWalletTokens:
return "CashuWalletTokens"
case KindCashuWalletHistory:
return "CashuWalletHistory"
case KindGeocacheLog:
return "GeocacheLog"
case KindGeocacheProofOfFind:
return "GeocacheProofOfFind"
case KindSimpleGroupPutUser: case KindSimpleGroupPutUser:
return "SimpleGroupPutUser" return "SimpleGroupPutUser"
case KindSimpleGroupRemoveUser: case KindSimpleGroupRemoveUser:
@@ -194,24 +152,14 @@ func (kind Kind) Name() string {
return "SearchRelayList" return "SearchRelayList"
case KindSimpleGroupList: case KindSimpleGroupList:
return "SimpleGroupList" return "SimpleGroupList"
case KindFavoriteRelaysList:
return "FavoriteRelaysList"
case KindPrivateEventRelayList:
return "PrivateEventRelayList"
case KindInterestList: case KindInterestList:
return "InterestList" return "InterestList"
case KindNutZapInfo: case KindNutZapInfo:
return "NutZapInfo" return "NutZapInfo"
case KindMediaFollows:
return "MediaFollows"
case KindEmojiList: case KindEmojiList:
return "EmojiList" return "EmojiList"
case KindDecoupledKeyAnnouncement:
return "DecoupledKeyAnnouncement"
case KindDMRelayList: case KindDMRelayList:
return "DMRelayList" return "DMRelayList"
case KindFavoritePodcasts:
return "FavoritePodcasts"
case KindUserServerList: case KindUserServerList:
return "UserServerList" return "UserServerList"
case KindFileStorageServerList: case KindFileStorageServerList:
@@ -220,26 +168,8 @@ func (kind Kind) Name() string {
return "GoodWikiAuthorList" return "GoodWikiAuthorList"
case KindGoodWikiRelayList: case KindGoodWikiRelayList:
return "GoodWikiRelayList" return "GoodWikiRelayList"
case KindPodcastMetadata:
return "PodcastMetadata"
case KindAuthoredPodcasts:
return "AuthoredPodcasts"
case KindRelayMonitorAnnouncement:
return "RelayMonitorAnnouncement"
case KindRoomPresence:
return "RoomPresence"
case KindUserGraspList:
return "UserGraspList"
case KindProxyAnnouncement:
return "ProxyAnnouncement"
case KindTransportMethodAnnouncement:
return "TransportMethodAnnouncement"
case KindNWCWalletInfo: case KindNWCWalletInfo:
return "NWCWalletInfo" return "NWCWalletInfo"
case KindNsiteRoot:
return "NsiteRoot"
case KindCashuWalletEvent:
return "CashuWalletEvent"
case KindLightningPubRPC: case KindLightningPubRPC:
return "LightningPubRPC" return "LightningPubRPC"
case KindClientAuthentication: case KindClientAuthentication:
@@ -296,20 +226,10 @@ func (kind Kind) Name() string {
return "ReleaseArtifactSets" return "ReleaseArtifactSets"
case KindApplicationSpecificData: case KindApplicationSpecificData:
return "ApplicationSpecificData" return "ApplicationSpecificData"
case KindRelayDiscovery:
return "RelayDiscovery"
case KindAppCurationSet:
return "AppCurationSet"
case KindLiveEvent: case KindLiveEvent:
return "LiveEvent" return "LiveEvent"
case KindInteractiveRoom:
return "InteractiveRoom"
case KindConferenceEvent:
return "ConferenceEvent"
case KindUserStatuses: case KindUserStatuses:
return "UserStatuses" return "UserStatuses"
case KindSlideSet:
return "SlideSet"
case KindClassifiedListing: case KindClassifiedListing:
return "ClassifiedListing" return "ClassifiedListing"
case KindDraftClassifiedListing: case KindDraftClassifiedListing:
@@ -318,14 +238,20 @@ func (kind Kind) Name() string {
return "RepositoryAnnouncement" return "RepositoryAnnouncement"
case KindRepositoryState: case KindRepositoryState:
return "RepositoryState" return "RepositoryState"
case KindSimpleGroupMetadata:
return "SimpleGroupMetadata"
case KindSimpleGroupAdmins:
return "SimpleGroupAdmins"
case KindSimpleGroupMembers:
return "SimpleGroupMembers"
case KindSimpleGroupRoles:
return "SimpleGroupRoles"
case KindSimpleGroupLiveKitParticipants:
return "SimpleGroupLiveKitParticipants"
case KindWikiArticle: case KindWikiArticle:
return "WikiArticle" return "WikiArticle"
case KindRedirects: case KindRedirects:
return "Redirects" return "Redirects"
case KindDraftEvent:
return "DraftEvent"
case KindLinkSet:
return "LinkSet"
case KindFeed: case KindFeed:
return "Feed" return "Feed"
case KindDateCalendarEvent: case KindDateCalendarEvent:
@@ -340,43 +266,14 @@ func (kind Kind) Name() string {
return "HandlerRecommendation" return "HandlerRecommendation"
case KindHandlerInformation: case KindHandlerInformation:
return "HandlerInformation" return "HandlerInformation"
case KindSoftwareApplication: case KindVideoEvent:
return "SoftwareApplication" return "VideoEvent"
case KindLegacyNsiteFile: case KindShortVideoEvent:
return "LegacyNsiteFile" return "ShortVideoEvent"
case KindVideoViewEvent: case KindVideoViewEvent:
return "VideoViewEvent" return "VideoViewEvent"
case KindCommunityDefinition: case KindCommunityDefinition:
return "CommunityDefinition" return "CommunityDefinition"
case KindNsiteNamed:
return "NsiteNamed"
case KindGeocacheListing:
return "GeocacheListing"
case KindGeocacheLogEntry:
return "GeocacheLogEntry"
case KindCashuMintAnnouncement:
return "CashuMintAnnouncement"
case KindFedimintAnnouncement:
return "FedimintAnnouncement"
case KindPeerToPeerOrderEvents:
return "PeerToPeerOrderEvents"
case KindSimpleGroupMetadata:
return "SimpleGroupMetadata"
case KindSimpleGroupAdmins:
return "SimpleGroupAdmins"
case KindSimpleGroupMembers:
return "SimpleGroupMembers"
case KindSimpleGroupRoles:
return "SimpleGroupRoles"
case KindSimpleGroupLiveKitParticipants:
return "SimpleGroupLiveKitParticipants"
case KindStarterPacks:
return "StarterPacks"
case KindMediaStarterPacks:
return "MediaStarterPacks"
case KindWebBookmarks:
return "WebBookmarks"
} }
return "unknown" return "unknown"
} }
@@ -397,37 +294,23 @@ const (
KindSimpleGroupReply Kind = 12 KindSimpleGroupReply Kind = 12
KindSeal Kind = 13 KindSeal Kind = 13
KindDirectMessage Kind = 14 KindDirectMessage Kind = 14
KindFileMessage Kind = 15
KindGenericRepost Kind = 16 KindGenericRepost Kind = 16
KindReactionToWebsite Kind = 17 KindReactionToWebsite Kind = 17
KindPhoto Kind = 20
KindNormalVideoEvent Kind = 21
KindShortVideoEvent Kind = 22
KindPublicMessage Kind = 24
KindChannelCreation Kind = 40 KindChannelCreation Kind = 40
KindChannelMetadata Kind = 41 KindChannelMetadata Kind = 41
KindChannelMessage Kind = 42 KindChannelMessage Kind = 42
KindChannelHideMessage Kind = 43 KindChannelHideMessage Kind = 43
KindChannelMuteUser Kind = 44 KindChannelMuteUser Kind = 44
KindPodcastEpisode Kind = 54
KindChess Kind = 64 KindChess Kind = 64
KindMergeRequests Kind = 818 KindMergeRequests Kind = 818
KindPollResponse Kind = 1018 KindComment Kind = 1111
KindBid Kind = 1021 KindBid Kind = 1021
KindBidConfirmation Kind = 1022 KindBidConfirmation Kind = 1022
KindOpenTimestamps Kind = 1040 KindOpenTimestamps Kind = 1040
KindGiftWrap Kind = 1059 KindGiftWrap Kind = 1059
KindFileMetadata Kind = 1063 KindFileMetadata Kind = 1063
KindPoll Kind = 1068
KindComment Kind = 1111
KindVoiceMessage Kind = 1222
KindScroll Kind = 1227
KindVoiceMessageComment Kind = 1244
KindLiveChatMessage Kind = 1311 KindLiveChatMessage Kind = 1311
KindCodeSnippet Kind = 1337
KindPatch Kind = 1617 KindPatch Kind = 1617
KindGitPullRequest Kind = 1618
KindGitPullRequestUpdate Kind = 1619
KindIssue Kind = 1621 KindIssue Kind = 1621
KindReply Kind = 1622 KindReply Kind = 1622
KindStatusOpen Kind = 1630 KindStatusOpen Kind = 1630
@@ -442,15 +325,8 @@ const (
KindTorrent Kind = 2003 KindTorrent Kind = 2003
KindTorrentComment Kind = 2004 KindTorrentComment Kind = 2004
KindCoinjoinPool Kind = 2022 KindCoinjoinPool Kind = 2022
KindDecoupledKeyClientAnnouncement Kind = 4454
KindDecoupledEncryptionKeyDistribution Kind = 4455
KindCommunityPostApproval Kind = 4550 KindCommunityPostApproval Kind = 4550
KindJobFeedback Kind = 7000 KindJobFeedback Kind = 7000
KindReservedCashuWalletTokens Kind = 7374
KindCashuWalletTokens Kind = 7375
KindCashuWalletHistory Kind = 7376
KindGeocacheLog Kind = 7516
KindGeocacheProofOfFind Kind = 7517
KindSimpleGroupPutUser Kind = 9000 KindSimpleGroupPutUser Kind = 9000
KindSimpleGroupRemoveUser Kind = 9001 KindSimpleGroupRemoveUser Kind = 9001
KindSimpleGroupEditMetadata Kind = 9002 KindSimpleGroupEditMetadata Kind = 9002
@@ -475,29 +351,15 @@ const (
KindBlockedRelayList Kind = 10006 KindBlockedRelayList Kind = 10006
KindSearchRelayList Kind = 10007 KindSearchRelayList Kind = 10007
KindSimpleGroupList Kind = 10009 KindSimpleGroupList Kind = 10009
KindFavoriteRelaysList Kind = 10012
KindPrivateEventRelayList Kind = 10013
KindInterestList Kind = 10015 KindInterestList Kind = 10015
KindNutZapInfo Kind = 10019 KindNutZapInfo Kind = 10019
KindMediaFollows Kind = 10020
KindEmojiList Kind = 10030 KindEmojiList Kind = 10030
KindDecoupledKeyAnnouncement Kind = 10044
KindDMRelayList Kind = 10050 KindDMRelayList Kind = 10050
KindFavoritePodcasts Kind = 10054
KindUserServerList Kind = 10063 KindUserServerList Kind = 10063
KindFileStorageServerList Kind = 10096 KindFileStorageServerList Kind = 10096
KindGoodWikiAuthorList Kind = 10101 KindGoodWikiAuthorList Kind = 10101
KindGoodWikiRelayList Kind = 10102 KindGoodWikiRelayList Kind = 10102
KindPodcastMetadata Kind = 10154
KindAuthoredPodcasts Kind = 10164
KindRelayMonitorAnnouncement Kind = 10166
KindRoomPresence Kind = 10312
KindUserGraspList Kind = 10317
KindProxyAnnouncement Kind = 10377
KindTransportMethodAnnouncement Kind = 11111
KindNWCWalletInfo Kind = 13194 KindNWCWalletInfo Kind = 13194
KindNsiteRoot Kind = 15128
KindCashuWalletEvent Kind = 17375
KindLightningPubRPC Kind = 21000 KindLightningPubRPC Kind = 21000
KindClientAuthentication Kind = 22242 KindClientAuthentication Kind = 22242
KindNWCWalletRequest Kind = 23194 KindNWCWalletRequest Kind = 23194
@@ -526,21 +388,19 @@ const (
KindModularArticleContent Kind = 30041 KindModularArticleContent Kind = 30041
KindReleaseArtifactSets Kind = 30063 KindReleaseArtifactSets Kind = 30063
KindApplicationSpecificData Kind = 30078 KindApplicationSpecificData Kind = 30078
KindRelayDiscovery Kind = 30166
KindAppCurationSet Kind = 30267
KindLiveEvent Kind = 30311 KindLiveEvent Kind = 30311
KindInteractiveRoom Kind = 30312
KindConferenceEvent Kind = 30313
KindUserStatuses Kind = 30315 KindUserStatuses Kind = 30315
KindSlideSet Kind = 30388
KindClassifiedListing Kind = 30402 KindClassifiedListing Kind = 30402
KindDraftClassifiedListing Kind = 30403 KindDraftClassifiedListing Kind = 30403
KindRepositoryAnnouncement Kind = 30617 KindRepositoryAnnouncement Kind = 30617
KindRepositoryState Kind = 30618 KindRepositoryState Kind = 30618
KindSimpleGroupMetadata Kind = 39000
KindSimpleGroupAdmins Kind = 39001
KindSimpleGroupMembers Kind = 39002
KindSimpleGroupRoles Kind = 39003
KindSimpleGroupLiveKitParticipants Kind = 39004
KindWikiArticle Kind = 30818 KindWikiArticle Kind = 30818
KindRedirects Kind = 30819 KindRedirects Kind = 30819
KindDraftEvent Kind = 31234
KindLinkSet Kind = 31388
KindFeed Kind = 31890 KindFeed Kind = 31890
KindDateCalendarEvent Kind = 31922 KindDateCalendarEvent Kind = 31922
KindTimeCalendarEvent Kind = 31923 KindTimeCalendarEvent Kind = 31923
@@ -548,24 +408,10 @@ const (
KindCalendarEventRSVP Kind = 31925 KindCalendarEventRSVP Kind = 31925
KindHandlerRecommendation Kind = 31989 KindHandlerRecommendation Kind = 31989
KindHandlerInformation Kind = 31990 KindHandlerInformation Kind = 31990
KindSoftwareApplication Kind = 32267 KindVideoEvent Kind = 34235
KindLegacyNsiteFile Kind = 34128 KindShortVideoEvent Kind = 34236
KindVideoViewEvent Kind = 34237 KindVideoViewEvent Kind = 34237
KindCommunityDefinition Kind = 34550 KindCommunityDefinition Kind = 34550
KindNsiteNamed Kind = 35128
KindGeocacheListing Kind = 37515
KindGeocacheLogEntry Kind = 37516
KindCashuMintAnnouncement Kind = 38172
KindFedimintAnnouncement Kind = 38173
KindPeerToPeerOrderEvents Kind = 38383
KindSimpleGroupMetadata Kind = 39000
KindSimpleGroupAdmins Kind = 39001
KindSimpleGroupMembers Kind = 39002
KindSimpleGroupRoles Kind = 39003
KindSimpleGroupLiveKitParticipants Kind = 39004
KindStarterPacks Kind = 39089
KindMediaStarterPacks Kind = 39092
KindWebBookmarks Kind = 39701
) )
func (kind Kind) IsRegular() bool { func (kind Kind) IsRegular() bool {
+15 -36
View File
@@ -8,18 +8,11 @@ import (
"strings" "strings"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
) )
type GroupAddress struct { type GroupAddress struct {
// URL of the relay that is hosting the group
Relay string Relay string
// Group identifier ("d"/"h" tag)
ID string ID string
// Public key of the relay, used to publish kind:39000/etc events
Self nostr.PubKey
} }
func (gid GroupAddress) String() string { func (gid GroupAddress) String() string {
@@ -27,10 +20,6 @@ func (gid GroupAddress) String() string {
return fmt.Sprintf("%s'%s", p.Host, gid.ID) return fmt.Sprintf("%s'%s", p.Host, gid.ID)
} }
func (gid GroupAddress) Code() string {
return nip19.EncodeNaddr(gid.Self, 39000, gid.ID, []string{gid.Relay})
}
func (gid GroupAddress) IsValid() bool { func (gid GroupAddress) IsValid() bool {
return gid.Relay != "" && gid.ID != "" return gid.Relay != "" && gid.ID != ""
} }
@@ -39,6 +28,14 @@ func (gid GroupAddress) Equals(gid2 GroupAddress) bool {
return gid.Relay == gid2.Relay && gid.ID == gid2.ID return gid.Relay == gid2.Relay && gid.ID == gid2.ID
} }
func ParseGroupAddress(raw string) (GroupAddress, error) {
spl := strings.Split(raw, "'")
if len(spl) != 2 {
return GroupAddress{}, fmt.Errorf("invalid group id")
}
return GroupAddress{ID: spl[1], Relay: nostr.NormalizeURL(spl[0])}, nil
}
type Group struct { type Group struct {
Address GroupAddress Address GroupAddress
@@ -66,12 +63,6 @@ type Group struct {
// indicates which event kinds this group supports // indicates which event kinds this group supports
SupportedKinds []nostr.Kind SupportedKinds []nostr.Kind
// arbitrary string indicating the parent group
Parent string
// ordered list of identifiers of child groups
Children []string
Roles []*Role Roles []*Role
InviteCodes []string InviteCodes []string
@@ -139,15 +130,15 @@ func (group Group) String() string {
} }
// NewGroup takes a group address in the form "<id>'<relay-hostname>" // NewGroup takes a group address in the form "<id>'<relay-hostname>"
func NewGroup(relayHost, groupId string) (Group, error) { func NewGroup(gadstr string) (Group, error) {
relayHost = nostr.NormalizeURL(relayHost) gad, err := ParseGroupAddress(gadstr)
if err != nil {
return Group{}, fmt.Errorf("invalid group id '%s': %w", gadstr, err)
}
return Group{ return Group{
Address: GroupAddress{ Address: gad,
Relay: relayHost, Name: gad.ID,
ID: groupId,
},
Name: groupId,
Members: make(map[nostr.PubKey][]*Role), Members: make(map[nostr.PubKey][]*Role),
LiveKitParticipants: make([]nostr.PubKey, 0), LiveKitParticipants: make([]nostr.PubKey, 0),
}, nil }, nil
@@ -212,14 +203,6 @@ func (group Group) ToMetadataEvent() nostr.Event {
evt.Tags = append(evt.Tags, tag) evt.Tags = append(evt.Tags, tag)
} }
if group.Parent != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"parent", group.Parent})
}
for _, child := range group.Children {
evt.Tags = append(evt.Tags, nostr.Tag{"child", child})
}
return evt return evt
} }
@@ -341,10 +324,6 @@ func (group *Group) MergeInMetadataEvent(evt *nostr.Event) error {
group.About = tag[1] group.About = tag[1]
case "picture": case "picture":
group.Picture = tag[1] group.Picture = tag[1]
case "parent":
group.Parent = tag[1]
case "child":
group.Children = append(group.Children, tag[1])
} }
} }
} }
+106 -31
View File
@@ -79,23 +79,40 @@ var moderationActionFactories = map[nostr.Kind]func(nostr.Event) (Action, error)
nostr.KindSimpleGroupEditMetadata: func(evt nostr.Event) (Action, error) { nostr.KindSimpleGroupEditMetadata: func(evt nostr.Event) (Action, error) {
ok := false ok := false
edit := EditMetadata{When: evt.CreatedAt} edit := EditMetadata{When: evt.CreatedAt}
y := true
n := false
hasName := false
// DEPRECATED: remove all the fields not tagged with Replace = true eventually
// edit-metadata to become a PUT rather than a PATCH
for _, tag := range evt.Tags { for _, tag := range evt.Tags {
if len(tag) >= 1 { if len(tag) >= 1 {
switch tag[0] { switch tag[0] {
case "name": case "name":
if len(tag) >= 2 { if len(tag) >= 2 {
edit.Group.Name = tag[1] edit.NameValue = &tag[1]
if ok {
edit.Replace = true
}
ok = true ok = true
hasName = true
} }
case "picture": case "picture":
if len(tag) >= 2 { if len(tag) >= 2 {
edit.Group.Picture = tag[1] edit.PictureValue = &tag[1]
if hasName {
edit.Replace = true
}
ok = true ok = true
} }
case "about": case "about":
if len(tag) >= 2 { if len(tag) >= 2 {
edit.Group.About = tag[1] edit.AboutValue = &tag[1]
if hasName {
edit.Replace = true
}
ok = true ok = true
} }
case "supported_kinds": case "supported_kinds":
@@ -107,33 +124,54 @@ var moderationActionFactories = map[nostr.Kind]func(nostr.Event) (Action, error)
kinds = append(kinds, nostr.Kind(kind)) kinds = append(kinds, nostr.Kind(kind))
} }
} }
edit.Group.SupportedKinds = kinds edit.SupportedKindsValue = &kinds
ok = true edit.Replace = true
case "closed": case "closed":
edit.Group.Closed = true edit.ClosedValue = &y
if hasName {
edit.Replace = true
}
ok = true
case "open":
edit.ClosedValue = &n
ok = true ok = true
case "restricted": case "restricted":
edit.Group.Restricted = true edit.RestrictedValue = &y
if hasName {
edit.Replace = true
}
ok = true
case "unrestricted":
edit.RestrictedValue = &n
ok = true ok = true
case "hidden": case "hidden":
edit.Group.Hidden = true edit.HiddenValue = &y
if hasName {
edit.Replace = true
}
ok = true
case "visible":
edit.HiddenValue = &n
ok = true ok = true
case "private": case "private":
edit.Group.Private = true edit.PrivateValue = &y
ok = true if hasName {
case "parent": edit.Replace = true
if len(tag) >= 2 {
edit.Group.Parent = tag[1]
ok = true
} }
ok = true
case "public":
edit.PrivateValue = &n
ok = true
case "livekit": case "livekit":
edit.Group.LiveKit = true edit.LiveKitValue = &y
edit.Replace = true
ok = true ok = true
case "child": case "no-livekit":
if len(tag) >= 2 { edit.LiveKitValue = &n
edit.Group.Children = append(edit.Group.Children, tag[1]) ok = true
case "no-text":
edit.SupportedKindsValue = nil
ok = true ok = true
}
} }
} }
} }
@@ -242,8 +280,17 @@ func (a RemoveUser) Apply(group *Group) {
} }
type EditMetadata struct { type EditMetadata struct {
Group NameValue *string
PictureValue *string
AboutValue *string
RestrictedValue *bool
ClosedValue *bool
HiddenValue *bool
PrivateValue *bool
LiveKitValue *bool
SupportedKindsValue *[]nostr.Kind
Replace bool
When nostr.Timestamp When nostr.Timestamp
} }
@@ -251,17 +298,45 @@ func (_ EditMetadata) Name() string { return "edit-metadata" }
func (a EditMetadata) Apply(group *Group) { func (a EditMetadata) Apply(group *Group) {
group.LastMetadataUpdate = a.When group.LastMetadataUpdate = a.When
group.Name = a.Group.Name if a.Replace {
group.Picture = a.Group.Picture group.Name = ""
group.About = a.Group.About group.Picture = ""
group.Restricted = a.Group.Restricted group.About = ""
group.Closed = a.Group.Closed group.Restricted = false
group.Hidden = a.Group.Hidden group.Closed = false
group.Private = a.Group.Private group.Hidden = false
group.LiveKit = a.Group.LiveKit group.Private = false
group.SupportedKinds = a.Group.SupportedKinds group.LiveKit = false
group.Parent = a.Group.Parent group.SupportedKinds = nil
group.Children = a.Group.Children }
if a.NameValue != nil {
group.Name = *a.NameValue
}
if a.PictureValue != nil {
group.Picture = *a.PictureValue
}
if a.AboutValue != nil {
group.About = *a.AboutValue
}
if a.RestrictedValue != nil {
group.Restricted = *a.RestrictedValue
}
if a.ClosedValue != nil {
group.Closed = *a.ClosedValue
}
if a.HiddenValue != nil {
group.Hidden = *a.HiddenValue
}
if a.PrivateValue != nil {
group.Private = *a.PrivateValue
}
if a.LiveKitValue != nil {
group.LiveKit = *a.LiveKitValue
}
if a.SupportedKindsValue != nil {
group.SupportedKinds = *a.SupportedKindsValue
}
} }
type CreateGroup struct { type CreateGroup struct {
+2 -2
View File
@@ -15,7 +15,7 @@ const (
) )
func TestGroupEventBackAndForth(t *testing.T) { func TestGroupEventBackAndForth(t *testing.T) {
group1, _ := NewGroup("relay.com", "xyz") group1, _ := NewGroup("relay.com'xyz")
group1.Name = "banana" group1.Name = "banana"
group1.Private = true group1.Private = true
meta1 := group1.ToMetadataEvent() meta1 := group1.ToMetadataEvent()
@@ -31,7 +31,7 @@ func TestGroupEventBackAndForth(t *testing.T) {
} }
require.True(t, hasPrivate, "translation of group1 to metadata event failed: %s", meta1) require.True(t, hasPrivate, "translation of group1 to metadata event failed: %s", meta1)
group2, _ := NewGroup("groups.com", "abc") group2, _ := NewGroup("groups.com'abc")
alicePub, _ := nostr.PubKeyFromHex(ALICE) alicePub, _ := nostr.PubKeyFromHex(ALICE)
group2.Members[alicePub] = []*Role{{Name: "nada"}} group2.Members[alicePub] = []*Role{{Name: "nada"}}
admins2 := group2.ToAdminsEvent() admins2 := group2.ToAdminsEvent()
+14 -30
View File
@@ -6,9 +6,7 @@ import (
"math/rand" "math/rand"
"net/url" "net/url"
"strconv" "strconv"
"sync"
"sync/atomic" "sync/atomic"
"time"
"unsafe" "unsafe"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
@@ -17,12 +15,6 @@ import (
"github.com/puzpuzpuz/xsync/v3" "github.com/puzpuzpuz/xsync/v3"
) )
var bunkerClientCtxKey = &struct{}{}
func IsBunkerClientOperation(ctx context.Context) bool {
return ctx.Value(bunkerClientCtxKey) == true
}
type BunkerClient struct { type BunkerClient struct {
Relays []string Relays []string
@@ -64,7 +56,6 @@ func ConnectBunker(
pool, pool,
onAuth, onAuth,
) )
_, err = bunker.RPC(ctx, "connect", []string{nostr.HexEncodeToString(parsed.HostPubKey[:]), parsed.Secret}) _, err = bunker.RPC(ctx, "connect", []string{nostr.HexEncodeToString(parsed.HostPubKey[:]), parsed.Secret})
return bunker, err return bunker, err
} }
@@ -140,10 +131,10 @@ func NewBunker(
} }
cancellableCtx, cancel := context.WithCancel(ctx) cancellableCtx, cancel := context.WithCancel(ctx)
bunkerClientCtx := context.WithValue(cancellableCtx, bunkerClientCtxKey, true)
_ = cancel _ = cancel
events, eosed := pool.SubscribeManyNotifyEOSE(bunkerClientCtx, relays, nostr.Filter{ go func() {
events := pool.SubscribeMany(cancellableCtx, relays, nostr.Filter{
Tags: nostr.TagMap{"p": []string{clientPublicKey.Hex()}}, Tags: nostr.TagMap{"p": []string{clientPublicKey.Hex()}},
Kinds: []nostr.Kind{nostr.KindNostrConnect}, Kinds: []nostr.Kind{nostr.KindNostrConnect},
Since: now, Since: now,
@@ -151,8 +142,6 @@ func NewBunker(
}, nostr.SubscriptionOptions{ }, nostr.SubscriptionOptions{
Label: "bunker46client", Label: "bunker46client",
}) })
go func() {
for ie := range events { for ie := range events {
if ie.Kind != nostr.KindNostrConnect { if ie.Kind != nostr.KindNostrConnect {
continue continue
@@ -185,15 +174,12 @@ func NewBunker(
// attempt switch_relays once every 10 times // attempt switch_relays once every 10 times
if now%10 == 0 { if now%10 == 0 {
swctx, cancel := context.WithTimeout(ctx, time.Second*3) if newRelays, _ := bunker.SwitchRelays(ctx); newRelays != nil {
if newRelays, _ := bunker.SwitchRelays(swctx); newRelays != nil { cancel()
bunker = NewBunker(ctx, clientSecretKey, targetPublicKey, newRelays, pool, func(string) {}) bunker = NewBunker(ctx, clientSecretKey, targetPublicKey, newRelays, pool, func(string) {})
} }
cancel()
} }
<-eosed
return bunker return bunker
} }
@@ -288,7 +274,7 @@ func (bunker *BunkerClient) NIP04Decrypt(
} }
func (bunker *BunkerClient) RPC(ctx context.Context, method string, params []string) (string, error) { func (bunker *BunkerClient) RPC(ctx context.Context, method string, params []string) (string, error) {
id := bunker.idPrefix + "-" + strconv.FormatUint(bunker.serial.Add(1), 10) + "-" + method id := bunker.idPrefix + "-" + strconv.FormatUint(bunker.serial.Add(1), 10)
req, err := json.Marshal(Request{ req, err := json.Marshal(Request{
ID: id, ID: id,
Method: method, Method: method,
@@ -317,23 +303,21 @@ func (bunker *BunkerClient) RPC(ctx context.Context, method string, params []str
bunker.listeners.Store(id, dispatcher) bunker.listeners.Store(id, dispatcher)
defer bunker.listeners.Delete(id) defer bunker.listeners.Delete(id)
relayConnectionWorked := make(chan struct{}) relayConnectionWorked := make(chan struct{})
relayConnectionWorkedO := sync.OnceFunc(func() {
close(relayConnectionWorked)
})
bunkerConnectionWorked := make(chan struct{}) bunkerConnectionWorked := make(chan struct{})
bunkerConnectionWorkedO := sync.OnceFunc(func() {
close(bunkerConnectionWorked)
})
bunkerClientCtx := context.WithValue(ctx, bunkerClientCtxKey, true)
for _, url := range bunker.Relays { for _, url := range bunker.Relays {
go func(url string) { go func(url string) {
relay, err := bunker.pool.EnsureRelay(url) relay, err := bunker.pool.EnsureRelay(url)
if err == nil { if err == nil {
relayConnectionWorkedO() select {
if err := relay.Publish(bunkerClientCtx, evt); err == nil { case relayConnectionWorked <- struct{}{}:
bunkerConnectionWorkedO() default:
}
if err := relay.Publish(ctx, evt); err == nil {
select {
case bunkerConnectionWorked <- struct{}{}:
default:
}
} }
} }
}(url) }(url)
-59
View File
@@ -3,8 +3,6 @@ package nip46
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"strconv"
"sync" "sync"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
@@ -52,50 +50,6 @@ func (p *DynamicSigner) Init() {
p.sessions = make(map[nostr.PubKey]map[nostr.PubKey]*Session) p.sessions = make(map[nostr.PubKey]map[nostr.PubKey]*Session)
} }
// HandleNostrConnectURI works like HandleRequest, but takes a nostrconnect:// URI as input, as scanned/pasted
// by the user, produced by the client. Since DynamicSigner can serve multiple handler keys, the caller must
// specify which handlerPubkey should respond to this connection.
func (p *DynamicSigner) HandleNostrConnectURI(ctx context.Context, handlerPubkey nostr.PubKey, uri *url.URL) (
resp Response,
eventResponse nostr.Event,
err error,
) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
return resp, eventResponse, err
}
secret := uri.Query().Get("secret")
_, handlerSecret, err := p.GetHandlerSecretKey(ctx, handlerPubkey)
if err != nil {
return resp, eventResponse, fmt.Errorf("no private key for %s: %w", handlerPubkey, err)
}
// pretend they started with a request
conversationKey, err := nip44.GenerateConversationKey(clientPublicKey, handlerSecret)
if err != nil {
return resp, eventResponse, err
}
reqj, _ := json.Marshal(Request{
ID: "nostrconnect-" + strconv.FormatInt(int64(nostr.Now()), 10),
Method: "imagined-nostrconnect",
Params: []string{clientPublicKey.Hex(), secret},
})
ciphertext, err := nip44.Encrypt(string(reqj), conversationKey)
if err != nil {
return resp, eventResponse, err
}
_, resp, eventResponse, err = p.HandleRequest(ctx, nostr.Event{
PubKey: clientPublicKey,
Kind: nostr.KindNostrConnect,
Content: ciphertext,
Tags: nostr.Tags{nostr.Tag{"p", handlerPubkey.Hex()}},
})
return resp, eventResponse, err
}
func (p *DynamicSigner) HandleRequest(ctx context.Context, event nostr.Event) ( func (p *DynamicSigner) HandleRequest(ctx context.Context, event nostr.Event) (
req Request, req Request,
resp Response, resp Response,
@@ -164,19 +118,6 @@ func (p *DynamicSigner) HandleRequest(ctx context.Context, event nostr.Event) (
var resultErr error var resultErr error
switch req.Method { switch req.Method {
case "imagined-nostrconnect":
// this is a fake request we pretend has existed, but was actually just we reading the nostrconnect:// uri
if len(req.Params) < 2 || req.Params[1] == "" {
resultErr = fmt.Errorf("needs a second argument 'secret'")
break
}
if p.OnConnect != nil {
if err := p.OnConnect(ctx, event.PubKey, req.Params[1]); err != nil {
resultErr = err
break
}
}
result = req.Params[1]
case "connect": case "connect":
var secret string var secret string
if len(req.Params) >= 2 { if len(req.Params) >= 2 {
-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
}
-151
View File
@@ -1,151 +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 {
if ns, err := nostr.NormalizeHTTPURL(s); err == nil {
event.Tags = append(event.Tags, nostr.Tag{"server", ns})
}
}
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
}
-236
View File
@@ -1,236 +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
}
-213
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"math" "math"
"net" "net"
"strconv"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
) )
@@ -241,121 +240,6 @@ func DecodeRequest(req Request) (MethodParams, error) {
Pubkey: pk, Pubkey: pk,
DisallowMethods: disallowedMethods, DisallowMethods: disallowedMethods,
}, nil }, nil
case "createrole":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
id, ok := req.Params[0].(string)
if !ok {
return nil, fmt.Errorf("missing id param for '%s'", req.Method)
}
var label, description string
if len(req.Params) >= 2 {
label, _ = req.Params[1].(string)
}
if len(req.Params) >= 3 {
description, _ = req.Params[2].(string)
}
var color, order int
if len(req.Params) >= 4 {
color = coerceInt(req.Params[3])
}
if len(req.Params) >= 5 {
order = coerceInt(req.Params[4])
}
return CreateRole{id, label, description, color, order}, nil
case "editrole":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
id, ok := req.Params[0].(string)
if !ok {
return nil, fmt.Errorf("missing id param for '%s'", req.Method)
}
var label, description string
if len(req.Params) >= 2 {
label, _ = req.Params[1].(string)
}
if len(req.Params) >= 3 {
description, _ = req.Params[2].(string)
}
var color, order int
if len(req.Params) >= 4 {
color = coerceInt(req.Params[3])
}
if len(req.Params) >= 5 {
order = coerceInt(req.Params[4])
}
return EditRole{id, label, description, color, order}, nil
case "deleterole":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
id, ok := req.Params[0].(string)
if !ok {
return nil, fmt.Errorf("missing id param for '%s'", req.Method)
}
return DeleteRole{id}, nil
case "assignrole":
if len(req.Params) < 2 {
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)
}
roleID, ok := req.Params[1].(string)
if !ok {
return nil, fmt.Errorf("missing role id param for '%s'", req.Method)
}
return AssignRole{pk, roleID}, nil
case "unassignrole":
if len(req.Params) < 2 {
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)
}
roleID, ok := req.Params[1].(string)
if !ok {
return nil, fmt.Errorf("missing role id param for '%s'", req.Method)
}
return UnassignRole{pk, roleID}, nil
case "signevent":
if len(req.Params) == 0 {
return nil, fmt.Errorf("invalid number of params for '%s'", req.Method)
}
tmpl, ok := req.Params[0].(map[string]any)
if !ok {
return nil, fmt.Errorf("missing event param for '%s'", req.Method)
}
kind, ok := tmpl["kind"].(float64)
if !ok || math.Trunc(kind) != kind {
return nil, fmt.Errorf("invalid kind '%v' for '%s'", tmpl["kind"], req.Method)
}
var createdAt nostr.Timestamp
if ca, ok := tmpl["created_at"]; ok {
caf, ok := ca.(float64)
if !ok || math.Trunc(caf) != caf {
return nil, fmt.Errorf("invalid created_at '%v' for '%s'", ca, req.Method)
}
createdAt = nostr.Timestamp(caf)
}
tags, err := coerceTags(tmpl["tags"])
if err != nil {
return nil, fmt.Errorf("invalid tags for '%s': %w", req.Method, err)
}
content, _ := tmpl["content"].(string)
return SignEvent{nostr.Kind(kind), createdAt, tags, content}, nil
case "stats": case "stats":
return Stats{}, nil return Stats{}, nil
default: default:
@@ -363,48 +247,6 @@ func DecodeRequest(req Request) (MethodParams, error) {
} }
} }
// coerceInt converts a decoded JSON param (a float64 number or a numeric
// string) into an int, returning 0 when the value is neither.
func coerceInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case string:
i, _ := strconv.Atoi(n)
return i
}
return 0
}
// coerceTags converts a decoded JSON value (an array of arrays of strings) into
// nostr.Tags. A nil value yields nil tags; any other shape is an error.
func coerceTags(v any) (nostr.Tags, error) {
if v == nil {
return nil, nil
}
rawTags, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("tags must be an array")
}
tags := make(nostr.Tags, len(rawTags))
for i, rt := range rawTags {
rawTag, ok := rt.([]any)
if !ok {
return nil, fmt.Errorf("tag %d must be an array", i)
}
tag := make(nostr.Tag, len(rawTag))
for j, el := range rawTag {
s, ok := el.(string)
if !ok {
return nil, fmt.Errorf("tag %d element %d must be a string", i, j)
}
tag[j] = s
}
tags[i] = tag
}
return tags, nil
}
type MethodParams interface { type MethodParams interface {
MethodName() string MethodName() string
} }
@@ -434,12 +276,6 @@ var (
_ MethodParams = (*ListDisallowedKinds)(nil) _ MethodParams = (*ListDisallowedKinds)(nil)
_ MethodParams = (*GrantAdmin)(nil) _ MethodParams = (*GrantAdmin)(nil)
_ MethodParams = (*RevokeAdmin)(nil) _ MethodParams = (*RevokeAdmin)(nil)
_ MethodParams = (*CreateRole)(nil)
_ MethodParams = (*EditRole)(nil)
_ MethodParams = (*DeleteRole)(nil)
_ MethodParams = (*AssignRole)(nil)
_ MethodParams = (*UnassignRole)(nil)
_ MethodParams = (*SignEvent)(nil)
_ MethodParams = (*Stats)(nil) _ MethodParams = (*Stats)(nil)
) )
@@ -579,55 +415,6 @@ type RevokeAdmin struct {
func (RevokeAdmin) MethodName() string { return "revokeadmin" } func (RevokeAdmin) MethodName() string { return "revokeadmin" }
type CreateRole struct {
ID string
Label string
Description string
Color int
Order int
}
func (CreateRole) MethodName() string { return "createrole" }
type EditRole struct {
ID string
Label string
Description string
Color int
Order int
}
func (EditRole) MethodName() string { return "editrole" }
type DeleteRole struct {
ID string
}
func (DeleteRole) MethodName() string { return "deleterole" }
type AssignRole struct {
PubKey nostr.PubKey
RoleID string
}
func (AssignRole) MethodName() string { return "assignrole" }
type UnassignRole struct {
PubKey nostr.PubKey
RoleID string
}
func (UnassignRole) MethodName() string { return "unassignrole" }
type SignEvent struct {
Kind nostr.Kind
CreatedAt nostr.Timestamp
Tags nostr.Tags
Content string
}
func (SignEvent) MethodName() string { return "signevent" }
type Stats struct{} type Stats struct{}
func (Stats) MethodName() string { return "stats" } func (Stats) MethodName() string { return "stats" }
+5 -38
View File
@@ -3,8 +3,6 @@ package blossom_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"net/http/httptest" "net/http/httptest"
@@ -20,18 +18,6 @@ import (
blossomclient "fiatjaf.com/nostr/nipb0/blossom" blossomclient "fiatjaf.com/nostr/nipb0/blossom"
) )
func hexTo32(s string) (h [32]byte, err error) {
b, err := hex.DecodeString(s)
if err != nil {
return h, err
}
if len(b) != 32 {
return h, errors.New("not 32 bytes")
}
copy(h[:], b)
return h, nil
}
func TestBlossomBasicOperations(t *testing.T) { func TestBlossomBasicOperations(t *testing.T) {
// setup two test servers // setup two test servers
server1 := setupTestServer(t, ":38081") server1 := setupTestServer(t, ":38081")
@@ -92,10 +78,7 @@ func TestBlossomBasicOperations(t *testing.T) {
t.Fatalf("Expected 1 blob, got %d", len(blobs)) t.Fatalf("Expected 1 blob, got %d", len(blobs))
} }
hash, err := hexTo32(blobs[0].SHA256) hash := blobs[0].SHA256
if err != nil {
t.Fatalf("Failed to parse hash: %v", err)
}
downloaded, err := client1.Download(ctx, hash) downloaded, err := client1.Download(ctx, hash)
if err != nil { if err != nil {
t.Fatalf("Failed to download blob: %v", err) t.Fatalf("Failed to download blob: %v", err)
@@ -190,12 +173,8 @@ func TestBlossomBasicOperations(t *testing.T) {
t.Errorf("Expected pubkey2 to still see 1 blob after pubkey1 delete, got %d", len(blobs2)) t.Errorf("Expected pubkey2 to still see 1 blob after pubkey1 delete, got %d", len(blobs2))
} }
hash32, err := hexTo32(hash)
if err != nil {
t.Fatalf("Failed to parse hash: %v", err)
}
// download should still work // download should still work
downloaded, err := client2.Download(ctx, hash32) downloaded, err := client2.Download(ctx, hash)
if err != nil { if err != nil {
t.Fatalf("Failed to download blob after pubkey1 delete: %v", err) t.Fatalf("Failed to download blob after pubkey1 delete: %v", err)
} }
@@ -224,12 +203,8 @@ func TestBlossomBasicOperations(t *testing.T) {
t.Errorf("Expected 1 blob on server2, got %d", len(blobs)) t.Errorf("Expected 1 blob on server2, got %d", len(blobs))
} }
hash32, err := hexTo32(bd.SHA256)
if err != nil {
t.Fatalf("Failed to parse hash: %v", err)
}
// verify download // verify download
downloaded, err := client2Server.Download(ctx, hash32) downloaded, err := client2Server.Download(ctx, bd.SHA256)
if err != nil { if err != nil {
t.Fatalf("Failed to download from server2: %v", err) t.Fatalf("Failed to download from server2: %v", err)
} }
@@ -271,21 +246,13 @@ func TestBlossomBasicOperations(t *testing.T) {
} }
// verify the mirrored blob can be downloaded // verify the mirrored blob can be downloaded
hash32, err := hexTo32(bd.SHA256) downloaded, err := client2Server.Download(ctx, bd.SHA256)
if err != nil {
t.Fatalf("Failed to parse hash: %v", err)
}
downloaded, err := client2Server.Download(ctx, hash32)
if err != nil { if err != nil {
t.Fatalf("Failed to download mirrored blob: %v", err) t.Fatalf("Failed to download mirrored blob: %v", err)
} }
hash32, err = hexTo32(blobs1[0].SHA256)
if err != nil {
t.Fatalf("Failed to parse hash: %v", err)
}
// should match the original content // should match the original content
originalDownloaded, err := client2.Download(ctx, hash32) originalDownloaded, err := client2.Download(ctx, blobs1[0].SHA256)
if err != nil { if err != nil {
t.Fatalf("Failed to download original blob for comparison: %v", err) t.Fatalf("Failed to download original blob for comparison: %v", err)
} }
+5 -14
View File
@@ -6,7 +6,6 @@ import (
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
) )
// Client represents a Blossom client for interacting with a media server // Client represents a Blossom client for interacting with a media server
@@ -29,18 +28,6 @@ func NewClient(mediaserver string, signer nostr.Signer) *Client {
// createHTTPClient creates a properly configured HTTP client // createHTTPClient creates a properly configured HTTP client
func createHTTPClient() *fasthttp.Client { func createHTTPClient() *fasthttp.Client {
d := fasthttpproxy.Dialer{
Timeout: 10 * time.Second,
ConnectTimeout: 10 * time.Second,
TCPDialer: fasthttp.TCPDialer{
// increase DNS cache time to an hour instead of default minute
Concurrency: 4096,
DNSCacheDuration: time.Hour,
},
DialDualStack: true,
}
dialFunc, _ := d.GetDialFunc(true)
return &fasthttp.Client{ return &fasthttp.Client{
MaxIdleConnDuration: time.Hour, MaxIdleConnDuration: time.Hour,
DisableHeaderNamesNormalizing: true, // because our headers are properly constructed DisableHeaderNamesNormalizing: true, // because our headers are properly constructed
@@ -48,7 +35,11 @@ func createHTTPClient() *fasthttp.Client {
Name: "nl-b", // user-agent Name: "nl-b", // user-agent
Dial: dialFunc, // increase DNS cache time to an hour instead of default minute
Dial: (&fasthttp.TCPDialer{
Concurrency: 4096,
DNSCacheDuration: time.Hour,
}).Dial,
} }
} }
+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)
+24 -113
View File
@@ -2,109 +2,37 @@ package blossom
import ( import (
"mime" "mime"
"strings"
) )
var commonMimeExtensions = map[string]string{
"application/json": ".json",
"application/pdf": ".pdf",
"application/vnd.android.package-archive": ".apk",
"application/vnd.sqlite3": ".sqlite3",
"application/xml": ".xml",
"audio/aac": ".aac",
"audio/flac": ".flac",
"audio/midi": ".midi",
"audio/mp3": ".mp3",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/webm": ".weba",
"audio/x-aiff": ".aiff",
"audio/x-m4a": ".m4a",
"image/avif": ".avif",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/css": ".css",
"text/csv": ".csv",
"text/html": ".html",
"text/javascript": ".js",
"text/markdown": ".md",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp2t": ".ts",
"video/mp4": ".mp4",
"video/ogg": ".ogv",
"video/quicktime": ".mov",
"video/webm": ".webm",
"video/x-matroska": ".mkv",
}
var commonExtensionMimes = map[string]string{
".aac": "audio/aac",
".aiff": "audio/x-aiff",
".apk": "application/vnd.android.package-archive",
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".csv": "text/csv; charset=utf-8",
".flac": "audio/flac",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".m4a": "audio/mp4",
".md": "text/markdown; charset=utf-8",
".midi": "audio/midi",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".oga": "audio/ogg",
".ogg": "audio/ogg",
".ogv": "video/ogg",
".pdf": "application/pdf",
".png": "image/png",
".sqlite3": "application/vnd.sqlite3",
".svg": "image/svg+xml",
".ts": "video/mp2t",
".txt": "text/plain; charset=utf-8",
".wav": "audio/wav",
".weba": "audio/webm",
".webm": "video/webm",
".webp": "image/webp",
".xml": "application/xml",
}
func normalizeMIMEType(mimetype string) string {
if mimetype == "" {
return ""
}
base, _, err := mime.ParseMediaType(mimetype)
if err == nil {
return strings.ToLower(base)
}
if idx := strings.IndexByte(mimetype, ';'); idx >= 0 {
mimetype = mimetype[:idx]
}
return strings.ToLower(strings.TrimSpace(mimetype))
}
func GetExtension(mimetype string) string { func GetExtension(mimetype string) string {
mimetype = normalizeMIMEType(mimetype)
if mimetype == "" { if mimetype == "" {
return "" return ""
} }
if ext, ok := commonMimeExtensions[mimetype]; ok { // hardcode some common cases (abd jbiwb oribkenatuc cases kuje ,ogg/.oga or .mov/.moov)
return ext switch mimetype {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "video/mp4":
return ".mp4"
case "application/vnd.android.package-archive":
return ".apk"
case "video/quicktime":
return ".mov"
case "application/vnd.sqlite3":
return "sqlite3"
case "text/markdown":
return "md"
case "audio/midi":
return "midi"
case "audio/x-aiff":
return "aiff"
} }
exts, _ := mime.ExtensionsByType(mimetype) exts, _ := mime.ExtensionsByType(mimetype)
@@ -114,20 +42,3 @@ func GetExtension(mimetype string) string {
return "" return ""
} }
func GetMIMEType(ext string) string {
if ext == "" {
return ""
}
ext = strings.ToLower(strings.TrimSpace(ext))
if ext != "" && ext[0] != '.' {
ext = "." + ext
}
if mimetype, ok := commonExtensionMimes[ext]; ok {
return mimetype
}
return mime.TypeByExtension(ext)
}
+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.IsAddressable() { {
// 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)
+36 -40
View File
@@ -24,30 +24,16 @@ type Pool struct {
Relays *xsync.MapOf[string, *Relay] Relays *xsync.MapOf[string, *Relay]
Context context.Context Context context.Context
authRequiredHandler func(context.Context, *Event) error
cancel context.CancelCauseFunc cancel context.CancelCauseFunc
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
// with the "auth-required:" prefix, only once for each relay
AuthRequiredHandler func(context.Context, *Event) error
// EventMiddleware is a function that will be called with all events received.
EventMiddleware func(RelayEvent) 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)
// 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) QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
// RelayOptions are any options that should be passed to Relays instantiated by this pool
RelayOptions RelayOptions RelayOptions RelayOptions
// custom things not often used // custom things not often used
penaltyBox *xsync.MapOf[string, [2]float64] penaltyBox *xsync.MapOf[string, [2]float64]
penaltyBoxCancel context.CancelFunc
} }
// DirectedFilter combines a Filter with a specific relay URL. // DirectedFilter combines a Filter with a specific relay URL.
@@ -74,20 +60,39 @@ func NewPool() *Pool {
} }
} }
func (pool *Pool) StartPenaltyBox() { type PoolOptions struct {
if pool.penaltyBoxCancel != nil { // AuthRequiredHandler, if given, must be a function that signs the auth event when called.
pool.penaltyBoxCancel() // 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
ctx, cancel := context.WithCancel(pool.Context) // PenaltyBox just sets the penalty box mechanism so relays that fail to connect
pool.penaltyBoxCancel = cancel // 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]() pool.penaltyBox = xsync.NewMapOf[string, [2]float64]()
go func() { go func() {
sleep := 30.0 sleep := 30.0
for { for {
select { select {
case <-ctx.Done(): case <-pool.Context.Done():
return return
case <-time.After(time.Duration(sleep) * time.Second): case <-time.After(time.Duration(sleep) * time.Second):
@@ -113,15 +118,6 @@ func (pool *Pool) StartPenaltyBox() {
}() }()
} }
func (pool *Pool) StopPenaltyBox() {
if pool.penaltyBoxCancel != nil {
pool.penaltyBoxCancel()
pool.penaltyBoxCancel = nil
}
pool.penaltyBox = nil
}
// AddToPenaltyBox manually adds a relay to the penalty box for the specified duration. // AddToPenaltyBox manually adds a relay to the penalty box for the specified duration.
// This prevents EnsureRelay from attempting to connect to the relay until the duration expires. // This prevents EnsureRelay from attempting to connect to the relay until the duration expires.
func (pool *Pool) AddToPenaltyBox(url string, duration time.Duration) { func (pool *Pool) AddToPenaltyBox(url string, duration time.Duration) {
@@ -159,7 +155,7 @@ func (pool *Pool) EnsureRelay(url string) (*Relay, error) {
if pool.penaltyBox != nil { if pool.penaltyBox != nil {
// putting relay in penalty box // putting relay in penalty box
pool.penaltyBox.Compute(nm, func(v [2]float64, loaded bool) (newV [2]float64, delete bool) { pool.penaltyBox.Compute(nm, func(v [2]float64, loaded bool) (newV [2]float64, delete bool) {
return [2]float64{v[0] + 1, math.Min(600.0, 30.0*math.Pow(1.5, v[0]+1))}, false return [2]float64{v[0] + 1, 30.0 + math.Pow(2, v[0]+1)}, false
}) })
pool.Relays.Store(nm, nil) // this is important for penalty box detection on EnsureRelay pool.Relays.Store(nm, nil) // this is important for penalty box detection on EnsureRelay
} }
@@ -211,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}
@@ -398,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
@@ -574,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 {
@@ -681,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 {
+3 -7
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.
@@ -494,11 +490,11 @@ func (r *Relay) Auth(ctx context.Context, sign func(context.Context, *Event) err
}, },
Content: "", Content: "",
} }
if err = sign(ctx, &authEvent); err != nil { if err := sign(ctx, &authEvent); err != nil {
err = fmt.Errorf("error signing auth event: %w", err) err = fmt.Errorf("error signing auth event: %w", err)
} else {
err = r.publish(ctx, authEvent.ID, &AuthEnvelope{Event: authEvent})
} }
err = r.publish(ctx, authEvent.ID, &AuthEnvelope{Event: authEvent})
}) })
if err == nil { if err == nil {
+65 -81
View File
@@ -18,7 +18,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml" const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/ffa18bf6fb5496d755b465b062e18c676df1a5d4/schema.yaml"
// this is used by hex.Decode in the "hex" validator -- we don't care about data races // this is used by hex.Decode in the "hex" validator -- we don't care about data races
var hexdummydecoder = make([]byte, 128) var hexdummydecoder = make([]byte, 128)
@@ -39,72 +39,62 @@ func FetchSchemaFromURL(schemaURL string) (Schema, error) {
return Schema{}, fmt.Errorf("failed to read schema response: %w", err) return Schema{}, fmt.Errorf("failed to read schema response: %w", err)
} }
return NewSchemaFromBytes(body) var schema Schema
} if err := yaml.Unmarshal(body, &schema); err != nil {
type Schema struct {
GenericTags map[string]ContentSpec `yaml:"generic_tags" json:"generic_tags,omitempty"`
Kinds map[string]*KindSchema `yaml:"kinds" json:"kinds,omitempty"`
}
type KindSchema struct {
Kind nostr.Kind `yaml:"-" json:"kind"`
Description string `yaml:"description" json:"description,omitempty"`
InUse bool `yaml:"in_use" json:"in_use,omitempty"`
Content ContentSpec `yaml:"content" json:"content,omitempty"`
Required []string `yaml:"required" json:"required,omitempty"`
Multiple []string `yaml:"multiple" json:"multiple,omitempty"`
Tags []TagSpec `yaml:"tags" json:"tags,omitempty"`
}
type TagSpec struct {
Name string `yaml:"name" json:"name,omitempty"`
Prefix string `yaml:"prefix" json:"prefix,omitempty"`
Next *ContentSpec `yaml:"next" json:"next,omitempty"`
}
type ContentSpec struct {
Type string `yaml:"type" json:"type,omitempty"`
Required bool `yaml:"required" json:"required,omitempty"`
Min int `yaml:"min" json:"min,omitempty"`
Max int `yaml:"max" json:"max,omitempty"`
Either []string `yaml:"either" json:"either,omitempty"`
Next *ContentSpec `yaml:"next" json:"next,omitempty"`
Variadic bool `yaml:"variadic" json:"variadic,omitempty"`
}
type Validator struct {
Schema Schema `json:"schema,omitempty"`
FailOnUnknownKind bool `json:"fail_on_unknown_kind,omitempty"`
FailOnUnknownType bool `json:"fail_on_unknown_type,omitempty"`
TypeValidators map[string]func(value string, spec *ContentSpec) error `json:"type_validators,omitempty"`
UnknownTypes []string `json:"unknown_types,omitempty"`
}
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
schema, err := NewSchemaFromBytes(schemaData)
if err != nil {
return Validator{}, err
}
return NewValidatorFromSchema(schema), nil
}
func NewSchemaFromBytes(schemaData []byte) (Schema, error) {
schema := Schema{
GenericTags: make(map[string]ContentSpec),
Kinds: make(map[string]*KindSchema),
}
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
return Schema{}, fmt.Errorf("failed to parse schema: %w", err) return Schema{}, fmt.Errorf("failed to parse schema: %w", err)
} }
for k := range schema.Kinds { return schema, nil
kn, _ := strconv.ParseUint(k, 10, 16) }
schema.Kinds[k].Kind = nostr.Kind(kn)
type Schema struct {
GenericTags map[string]ContentSpec `yaml:"generic_tags"`
Kinds map[string]KindSchema `yaml:"kinds"`
}
type KindSchema struct {
Description string `yaml:"description"`
InUse bool `yaml:"in_use"`
Content ContentSpec `yaml:"content"`
Required []string `yaml:"required"`
Multiple []string `yaml:"multiple"`
Tags []TagSpec `yaml:"tags"`
}
type TagSpec struct {
Name string `yaml:"name"`
Prefix string `yaml:"prefix"`
Next *ContentSpec `yaml:"next"`
}
type ContentSpec struct {
Type string `yaml:"type"`
Required bool `yaml:"required"`
Min int `yaml:"min"`
Max int `yaml:"max"`
Either []string `yaml:"either"`
Next *ContentSpec `yaml:"next"`
Variadic bool `yaml:"variadic"`
}
type Validator struct {
Schema Schema
FailOnUnknownKind bool
FailOnUnknownType bool
TypeValidators map[string]func(value string, spec *ContentSpec) error
UnknownTypes []string
}
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
schema := Schema{
GenericTags: make(map[string]ContentSpec),
Kinds: make(map[string]KindSchema),
}
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
return Validator{}, fmt.Errorf("failed to parse schema: %w", err)
} }
return schema, nil return NewValidatorFromSchema(schema), nil
} }
func NewValidatorFromSchema(sch Schema) Validator { func NewValidatorFromSchema(sch Schema) Validator {
@@ -195,19 +185,11 @@ func NewValidatorFromSchema(sch Schema) Validator {
} }
func NewValidatorFromFile(filename string) (Validator, error) { func NewValidatorFromFile(filename string) (Validator, error) {
schema, err := NewSchemaFromFile(filename)
if err != nil {
return Validator{}, err
}
return NewValidatorFromSchema(schema), nil
}
func NewSchemaFromFile(filename string) (Schema, error) {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
if err != nil { if err != nil {
return Schema{}, fmt.Errorf("failed to read schema file: %w", err) return Validator{}, fmt.Errorf("failed to read schema file: %w", err)
} }
return NewSchemaFromBytes(data) return NewValidatorFromBytes(data)
} }
func NewValidatorFromURL(schemaURL string) (Validator, error) { func NewValidatorFromURL(schemaURL string) (Validator, error) {
@@ -230,7 +212,7 @@ var (
) )
type UnknownTypes struct { type UnknownTypes struct {
Types []string `json:"types,omitempty"` Types []string
} }
func (ut UnknownTypes) Error() string { func (ut UnknownTypes) Error() string {
@@ -238,7 +220,7 @@ func (ut UnknownTypes) Error() string {
} }
type ContentError struct { type ContentError struct {
Err error `json:"err,omitempty"` Err error
} }
func (ce ContentError) Error() string { func (ce ContentError) Error() string {
@@ -246,9 +228,9 @@ func (ce ContentError) Error() string {
} }
type TagError struct { type TagError struct {
Tag int `json:"tag,omitempty"` Tag int
Item int `json:"item,omitempty"` Item int
Err error `json:"err,omitempty"` Err error
} }
func (te TagError) Error() string { func (te TagError) Error() string {
@@ -256,7 +238,7 @@ func (te TagError) Error() string {
} }
type RequiredTagError struct { type RequiredTagError struct {
Missing []string `json:"missing,omitempty"` Missing []string
} }
func (rte RequiredTagError) Error() string { func (rte RequiredTagError) Error() string {
@@ -264,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 {
@@ -403,9 +389,7 @@ func (v *Validator) validateNext(tag nostr.Tag, index int, this *ContentSpec) (f
return index, ErrDanglingSpace return index, ErrDanglingSpace
} }
if tag[index] == "" && !this.Required { if validator, ok := v.TypeValidators[this.Type]; ok {
// empty string ok for non-required items
} else if validator, ok := v.TypeValidators[this.Type]; ok {
if err := validator(tag[index], this); err != nil { if err := validator(tag[index], this); err != nil {
return index, fmt.Errorf("invalid %s value '%s' at tag '%s', index %d: %w", return index, fmt.Errorf("invalid %s value '%s' at tag '%s', index %d: %w",
this.Type, tag[index], tag[0], index, err) this.Type, tag[index], tag[0], index, err)
+1 -1
View File
@@ -94,7 +94,7 @@ func fetchGenericList[V comparable, I TagItemWithValue[V]](
// we'll only save this if we got something which means we found at least one event // we'll only save this if we got something which means we found at least one event
lastFetchKey := makeLastFetchKey(actualKind, pubkey) lastFetchKey := makeLastFetchKey(actualKind, pubkey)
sys.KVStore.Set(lastFetchKey, encodeTimestamp(nostr.Now())) sys.KVStore.Set(lastFetchKey, encodeTimestamp(nostr.Now()))
sys.Store.ReplaceEvent(*v.Event) sys.Store.SaveEvent(*v.Event)
} }
// save cache even if we didn't get anything // save cache even if we didn't get anything
-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
}
+1 -1
View File
@@ -142,7 +142,7 @@ func (sys *System) FetchProfileMetadata(ctx context.Context, pubkey nostr.PubKey
if newM := sys.tryFetchMetadataFromNetwork(ctx, pubkey); newM != nil { if newM := sys.tryFetchMetadataFromNetwork(ctx, pubkey); newM != nil {
pm = *newM pm = *newM
sys.Store.ReplaceEvent(*pm.Event) sys.Store.SaveEvent(*pm.Event)
// we'll only save this if we got something which means we found at least one event // we'll only save this if we got something which means we found at least one event
lastFetchKey := makeLastFetchKey(0, pubkey) lastFetchKey := makeLastFetchKey(0, pubkey)
-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
+3 -1
View File
@@ -22,7 +22,9 @@ func IsVirtualRelay(url string) bool {
return true return true
} }
if !testing.Testing() && (strings.HasPrefix(url, "ws://localhost") || strings.HasPrefix(url, "ws://127.0.0.1")) { if !testing.Testing() &&
strings.HasPrefix(url, "ws://localhost") ||
strings.HasPrefix(url, "ws://127.0.0.1") {
return true return true
} }
+32 -12
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,12 +76,19 @@ start:
actualcall: actualcall:
var res WotXorFilter var res WotXorFilter
m := sys.loadWoT(ctx, pubkey) m, err := sys.loadWoT(ctx, pubkey)
if err != nil {
wc.mutex.Lock()
for _, ch := range wc.errorbacks {
ch <- err
}
} else {
res = makeWoTFilter(m) res = makeWoTFilter(m)
wc.mutex.Lock() wc.mutex.Lock()
for _, ch := range wc.resultbacks { for _, ch := range wc.resultbacks {
ch <- res ch <- res
} }
}
wotCallsMutex.Lock() wotCallsMutex.Lock()
wotCallsInPlace[pos] = nil wotCallsInPlace[pos] = nil
@@ -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)
g.Go(func() error { // process follow lists
wg := sync.WaitGroup{}
wg.Add(1)
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,19 +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
}) })
} }
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 {
+16 -8
View File
@@ -14,7 +14,8 @@ func TestLoadWoT(t *testing.T) {
ctx := t.Context() ctx := t.Context()
// test with fiatjaf's pubkey // test with fiatjaf's pubkey
wotch := sys.loadWoT(ctx, nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")) wotch, err := sys.loadWoT(ctx, nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))
require.NoError(t, err)
wot := make([]nostr.PubKey, 0, 100000) wot := make([]nostr.PubKey, 0, 100000)
wotch2 := make(chan nostr.PubKey) wotch2 := make(chan nostr.PubKey)
@@ -59,7 +60,8 @@ func TestLoadWoTManyPeople(t *testing.T) {
// these are the same pubkey // these are the same pubkey
go func() { go func() {
rabble := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa")) rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
require.NoError(t, err)
diffs[0] = nostr.Now() diffs[0] = nostr.Now()
rabble1 = rabble rabble1 = rabble
wg.Done() wg.Done()
@@ -67,7 +69,8 @@ func TestLoadWoTManyPeople(t *testing.T) {
time.Sleep(time.Millisecond * 20) time.Sleep(time.Millisecond * 20)
go func() { go func() {
rabble := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa")) rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
require.NoError(t, err)
diffs[1] = nostr.Now() diffs[1] = nostr.Now()
rabble2 = rabble rabble2 = rabble
wg.Done() wg.Done()
@@ -75,7 +78,8 @@ func TestLoadWoTManyPeople(t *testing.T) {
time.Sleep(time.Millisecond * 20) time.Sleep(time.Millisecond * 20)
go func() { go func() {
rabble := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa")) rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
require.NoError(t, err)
diffs[2] = nostr.Now() diffs[2] = nostr.Now()
rabble3 = rabble rabble3 = rabble
wg.Done() wg.Done()
@@ -84,7 +88,8 @@ func TestLoadWoTManyPeople(t *testing.T) {
// these should map to the same pos // these should map to the same pos
time.Sleep(time.Millisecond * 20) time.Sleep(time.Millisecond * 20)
go func() { go func() {
alex := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7")) alex, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7"))
require.NoError(t, err)
diffs[3] = nostr.Now() diffs[3] = nostr.Now()
alex1 = alex alex1 = alex
wg.Done() wg.Done()
@@ -92,7 +97,8 @@ func TestLoadWoTManyPeople(t *testing.T) {
time.Sleep(time.Millisecond * 20) time.Sleep(time.Millisecond * 20)
go func() { go func() {
alex := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7")) alex, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7"))
require.NoError(t, err)
diffs[4] = nostr.Now() diffs[4] = nostr.Now()
alex2 = alex alex2 = alex
wg.Done() wg.Done()
@@ -100,14 +106,16 @@ func TestLoadWoTManyPeople(t *testing.T) {
// these are independent // these are independent
go func() { go func() {
hodlbod := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")) hodlbod, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"))
require.NoError(t, err)
require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49"))) require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49")))
require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))) require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa")))
require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))) require.True(t, hodlbod.Contains(nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")))
wg.Done() wg.Done()
}() }()
go func() { go func() {
mikedilger := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49")) mikedilger, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49"))
require.NoError(t, err)
require.True(t, mikedilger.Contains(nostr.MustPubKeyFromHex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"))) require.True(t, mikedilger.Contains(nostr.MustPubKeyFromHex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")))
require.True(t, mikedilger.Contains(nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))) require.True(t, mikedilger.Contains(nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")))
wg.Done() wg.Done()
+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