Compare commits
70 Commits
4daeb8737c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a525b66054 | |||
| 545f2109ae | |||
| 5b3876cc1f | |||
| 8389bac80c | |||
| 7d16aa1168 | |||
| ab19d4fc8e | |||
| 724550d2a4 | |||
| 347dba8d60 | |||
| 762950f6b3 | |||
| 8d018c0c04 | |||
| 5232b167db | |||
| 0d1577c4de | |||
| 71307ba4c1 | |||
| 0f8843afac | |||
| 0616b30ab3 | |||
| c4534c7160 | |||
| bd9746b22b | |||
| 12ec5cd2d9 | |||
| 1e90b7f018 | |||
| 7bfb4828ce | |||
| 245a47bc03 | |||
| d48b1f7c33 | |||
| 5a135f5b86 | |||
| 395c960955 | |||
| 03e9b68f93 | |||
| b7dea9e06a | |||
| 015842e96d | |||
| c639b10f9a | |||
| 05237b3463 | |||
| 8bc1d8ce7f | |||
| 13813b502a | |||
| bb562d76a7 | |||
| c523fb0c8a | |||
| 5d9b5916d2 | |||
| e11e32e3e2 | |||
| b70dd86e7c | |||
| e259db5881 | |||
| d27cf276d1 | |||
| 8634f0f7d5 | |||
| b3cef7b425 | |||
| 9911767e78 | |||
| 19fe80a8a7 | |||
| 67e008e8c7 | |||
| a4c590d923 | |||
| 03a55cc0b8 | |||
| a5aeff31d7 | |||
| bf7998e780 | |||
| 61586d5d1b | |||
| c75bd45d13 | |||
| aafff41d40 | |||
| cbf335a8fa | |||
| 05b426e67e | |||
| 744fb0702c | |||
| b899ef8865 | |||
| 696f377109 | |||
| e144b33fa2 | |||
| 42379e53a2 | |||
| e2ad68d050 | |||
| 223d95461f | |||
| 078ee94465 | |||
| a21ea55eaa | |||
| 5b28d08e47 | |||
| 94ea432818 | |||
| 8200164174 | |||
| f50b7b0f8d | |||
| 31473172a9 | |||
| d56bdba3ff | |||
| 7dc553f71b | |||
| fbd4dddba3 | |||
| c11e94a04b |
@@ -2,7 +2,9 @@ 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"
|
||||||
@@ -26,10 +28,17 @@ 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 {
|
||||||
return sha256.Sum256(evt.Serialize())
|
var id ID
|
||||||
|
evt.serializedHash(&id)
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckID checks if the implied ID matches the given ID more efficiently.
|
// SetID calculates and sets the id to the event in a single operation.
|
||||||
|
func (evt *Event) SetID() {
|
||||||
|
evt.serializedHash(&evt.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckID checks if the implied ID matches the currently assigned ID.
|
||||||
func (evt Event) CheckID() bool {
|
func (evt Event) CheckID() bool {
|
||||||
return evt.GetID() == evt.ID
|
return evt.GetID() == evt.ID
|
||||||
}
|
}
|
||||||
@@ -38,17 +47,56 @@ 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, 4+64, 100+len(evt.Content)+len(evt.Tags)*80)
|
dst := make([]byte, 0, 100+len(evt.Content)+len(evt.Tags)*80)
|
||||||
|
return evt.appendSerialized(dst)
|
||||||
|
}
|
||||||
|
|
||||||
// the header portion is easy to serialize
|
var escTable [256]bool
|
||||||
// [0,"pubkey",created_at,kind,[
|
|
||||||
copy(dst, `[0,"`)
|
// pre-built escape sequences; index by the offending byte.
|
||||||
xhex.Encode(dst[4:4+64], evt.PubKey[:]) // there will always be such capacity
|
var escSeq [256][2]byte
|
||||||
|
|
||||||
|
// pre-built []byte slices for hash.Write calls (no per-call allocation).
|
||||||
|
var escSlice [256][]byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
jsonQuote = []byte{'"'}
|
||||||
|
serializedStart = []byte(`[0,"`)
|
||||||
|
serializedPubkeyEnd = []byte(`",`)
|
||||||
|
serializedTagsEnd = []byte("],")
|
||||||
|
serializedTagStart = []byte{'['}
|
||||||
|
serializedTagEnd = []byte{']'}
|
||||||
|
serializedComma = []byte{','}
|
||||||
|
serializedEnd = []byte{']'}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, b := range []byte{'"', '\\', '\n', '\r', '\t'} {
|
||||||
|
escTable[b] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
escSeq['"'] = [2]byte{'\\', '"'}
|
||||||
|
escSeq['\\'] = [2]byte{'\\', '\\'}
|
||||||
|
escSeq['\n'] = [2]byte{'\\', 'n'}
|
||||||
|
escSeq['\r'] = [2]byte{'\\', 'r'}
|
||||||
|
escSeq['\t'] = [2]byte{'\\', 't'}
|
||||||
|
for b, seq := range escSeq {
|
||||||
|
if escTable[b] {
|
||||||
|
escSlice[b] = seq[:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (evt Event) appendSerialized(dst []byte) []byte {
|
||||||
|
start := len(dst)
|
||||||
|
dst = append(dst, `[0,"`...)
|
||||||
|
dst = append(dst, make([]byte, 64)...)
|
||||||
|
xhex.Encode(dst[start+4:start+4+64], evt.PubKey[:])
|
||||||
dst = append(dst, `",`...)
|
dst = append(dst, `",`...)
|
||||||
dst = append(dst, strconv.FormatInt(int64(evt.CreatedAt), 10)...)
|
dst = strconv.AppendInt(dst, int64(evt.CreatedAt), 10)
|
||||||
dst = append(dst, `,`...)
|
dst = append(dst, ',')
|
||||||
dst = append(dst, strconv.FormatUint(uint64(evt.Kind), 10)...)
|
dst = strconv.AppendUint(dst, uint64(evt.Kind), 10)
|
||||||
dst = append(dst, `,`...)
|
dst = append(dst, ',')
|
||||||
|
|
||||||
// tags
|
// tags
|
||||||
dst = append(dst, '[')
|
dst = append(dst, '[')
|
||||||
@@ -62,15 +110,167 @@ func (evt Event) Serialize() []byte {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
dst = append(dst, ',')
|
dst = append(dst, ',')
|
||||||
}
|
}
|
||||||
dst = escapeString(dst, s)
|
dst = appendJSONString(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 = escapeString(dst, evt.Content)
|
dst = appendJSONString(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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-18
@@ -1,8 +1,12 @@
|
|||||||
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"
|
||||||
@@ -102,23 +106,49 @@ func TestIDCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkIDCheck(b *testing.B) {
|
func BenchmarkEventVerifySignatureJSONL(b *testing.B) {
|
||||||
evt := Event{
|
events := loadBenchmarkEvents(b)
|
||||||
CreatedAt: Timestamp(rand.Int64N(9999999)),
|
b.ReportAllocs()
|
||||||
Content: fmt.Sprintf("hello"),
|
b.ResetTimer()
|
||||||
Tags: Tags{},
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
for _, evt := range events {
|
||||||
|
if !evt.VerifySignature() {
|
||||||
|
b.Fatal("signature verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
evt.Sign(Generate())
|
}
|
||||||
|
|
||||||
b.Run("naïve", func(b *testing.B) {
|
func loadBenchmarkEvents(b *testing.B) []Event {
|
||||||
for b.Loop() {
|
b.Helper()
|
||||||
_ = evt.GetID() == evt.ID
|
|
||||||
}
|
f, err := os.Open("testdata/events.jsonl")
|
||||||
})
|
require.NoError(b, err)
|
||||||
|
b.Cleanup(func() { _ = f.Close() })
|
||||||
b.Run("big brain", func(b *testing.B) {
|
|
||||||
for b.Loop() {
|
r := bufio.NewReader(f)
|
||||||
_ = evt.CheckID()
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -21,6 +24,7 @@ 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")
|
||||||
@@ -74,3 +78,184 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-23
@@ -202,9 +202,12 @@ func (b *BleveBackend) Init() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.index = index
|
b.index = index
|
||||||
b.detector = lingua.NewLanguageDetectorBuilder().
|
|
||||||
FromLanguages(b.Languages...).
|
if len(b.Languages) >= 2 {
|
||||||
Build()
|
b.detector = lingua.NewLanguageDetectorBuilder().
|
||||||
|
FromLanguages(b.Languages...).
|
||||||
|
Build()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -248,25 +251,25 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
|
|||||||
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
|
evt.Content = pm.Name + "\n" + pm.DisplayName + "\n" + pm.About
|
||||||
references = append(references, pm.NIP05)
|
references = append(references, pm.NIP05)
|
||||||
}
|
}
|
||||||
case 9802:
|
}
|
||||||
for _, tag := range evt.Tags {
|
|
||||||
if len(tag) < 2 {
|
for _, tag := range evt.Tags {
|
||||||
continue
|
if len(tag) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch tag[0] {
|
||||||
|
case "comment", "name", "title", "about", "description":
|
||||||
|
evt.Content += "\n\n" + tag[1]
|
||||||
|
case "e":
|
||||||
|
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
|
||||||
|
references = append(references, ptr.AsTagReference())
|
||||||
}
|
}
|
||||||
switch tag[0] {
|
case "a":
|
||||||
case "comment":
|
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
|
||||||
evt.Content += "\n\n" + tag[1]
|
references = append(references, ptr.AsTagReference())
|
||||||
case "e":
|
|
||||||
if ptr, err := nostr.EventPointerFromTag(tag); err == nil {
|
|
||||||
references = append(references, ptr.AsTagReference())
|
|
||||||
}
|
|
||||||
case "a":
|
|
||||||
if ptr, err := nostr.EntityPointerFromTag(tag); err == nil {
|
|
||||||
references = append(references, ptr.AsTagReference())
|
|
||||||
}
|
|
||||||
case "r":
|
|
||||||
references = append(references, tag[1])
|
|
||||||
}
|
}
|
||||||
|
case "r":
|
||||||
|
references = append(references, tag[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,9 +294,16 @@ func (b *BleveBackend) indexEvent(evt nostr.Event) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
indexableContent := content.String()
|
indexableContent := content.String()
|
||||||
lang, ok := b.detector.DetectLanguageOf(indexableContent)
|
|
||||||
if !ok {
|
var lang lingua.Language
|
||||||
lang = lingua.English
|
if len(b.Languages) == 1 {
|
||||||
|
lang = b.Languages[0]
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
lang, ok = b.detector.DetectLanguageOf(indexableContent)
|
||||||
|
if !ok {
|
||||||
|
lang = lingua.English
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var analyzerLangCode string
|
var analyzerLangCode string
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -2,14 +2,32 @@ 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 {
|
||||||
@@ -150,3 +168,184 @@ 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,8 +1,11 @@
|
|||||||
package mmm
|
package mmm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -125,6 +128,100 @@ 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 {
|
||||||
@@ -142,7 +239,13 @@ 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 {
|
||||||
@@ -157,7 +260,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 := range all {
|
for i, fr := 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)
|
||||||
@@ -165,6 +268,17 @@ 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 {
|
||||||
@@ -176,3 +290,191 @@ 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ 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))
|
||||||
@@ -46,6 +41,7 @@ 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]),
|
||||||
@@ -53,6 +49,7 @@ 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)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -119,7 +120,8 @@ 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
|
||||||
b.freeRangesAll = b.freeRangesAll.del(fr.start)
|
idx := b.freeRangesAll.find(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{
|
||||||
@@ -155,7 +157,7 @@ func (b *MultiMmapManager) storeOn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// write to the mmap
|
// write to the mmap
|
||||||
if err := betterbinary.Marshal(evt, b.mmapf[pos.start:]); err != nil {
|
if err := betterbinary.Marshal(evt, b.mmapf[pos.start:pos.start+uint64(pos.size)]); 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
int(-360)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
int(-17)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
int(46)
|
||||||
|
uint(84)
|
||||||
|
uint(55)
|
||||||
|
uint(5)
|
||||||
@@ -40,7 +40,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/lib v0.3.6
|
fiatjaf.com/lib v0.3.7
|
||||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||||
github.com/go-git/go-git/v5 v5.16.3
|
github.com/go-git/go-git/v5 v5.16.3
|
||||||
github.com/pemistahl/lingua-go v1.4.0
|
github.com/pemistahl/lingua-go v1.4.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
|
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
|
||||||
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
|
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
|
||||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
|
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
|
||||||
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
|
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
|
||||||
|
|||||||
-40
@@ -92,46 +92,6 @@ func similarPublicKey(as, bs []PubKey) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escaping strings for JSON encoding according to RFC8259.
|
|
||||||
// Also encloses result in quotation marks "".
|
|
||||||
func escapeString(dst []byte, s string) []byte {
|
|
||||||
dst = append(dst, '"')
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
switch {
|
|
||||||
case c == '"':
|
|
||||||
// quotation mark
|
|
||||||
dst = append(dst, []byte{'\\', '"'}...)
|
|
||||||
case c == '\\':
|
|
||||||
// reverse solidus
|
|
||||||
dst = append(dst, []byte{'\\', '\\'}...)
|
|
||||||
case c >= 0x20:
|
|
||||||
// default, rest below are control chars
|
|
||||||
dst = append(dst, c)
|
|
||||||
case c == 0x08:
|
|
||||||
dst = append(dst, []byte{'\\', 'b'}...)
|
|
||||||
case c < 0x09:
|
|
||||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...)
|
|
||||||
case c == 0x09:
|
|
||||||
dst = append(dst, []byte{'\\', 't'}...)
|
|
||||||
case c == 0x0a:
|
|
||||||
dst = append(dst, []byte{'\\', 'n'}...)
|
|
||||||
case c == 0x0c:
|
|
||||||
dst = append(dst, []byte{'\\', 'f'}...)
|
|
||||||
case c == 0x0d:
|
|
||||||
dst = append(dst, []byte{'\\', 'r'}...)
|
|
||||||
case c < 0x10:
|
|
||||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...)
|
|
||||||
case c < 0x1a:
|
|
||||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...)
|
|
||||||
case c < 0x20:
|
|
||||||
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dst = append(dst, '"')
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
func subIdToSerial(subId string) int64 {
|
func subIdToSerial(subId string) int64 {
|
||||||
n := strings.Index(subId, ":")
|
n := strings.Index(subId, ":")
|
||||||
if n < 0 || n > len(subId) {
|
if n < 0 || n > len(subId) {
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ func (rl *Relay) handleNormal(ctx context.Context, evt nostr.Event) (skipBroadca
|
|||||||
} else {
|
} else {
|
||||||
// otherwise it's a replaceable
|
// otherwise it's a replaceable
|
||||||
if nil != rl.ReplaceEvent {
|
if nil != rl.ReplaceEvent {
|
||||||
if _, err := rl.ReplaceEvent(ctx, evt); err != nil {
|
if err := rl.ReplaceEvent(ctx, evt); err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case eventstore.ErrDupEvent:
|
case eventstore.ErrDupEvent:
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ 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 (
|
||||||
|
|||||||
@@ -59,6 +59,30 @@ 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),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -129,7 +128,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 := mime.TypeByExtension(ext)
|
mimeType := blossom.GetMIMEType(ext)
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
mimeType = "application/octet-stream"
|
mimeType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ 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
@@ -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 target.PubKey == evt.PubKey {
|
if rl.AllowDeleting == nil && target.PubKey == evt.PubKey || rl.AllowDeleting != nil && rl.AllowDeleting(ctx, target, evt) {
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package khatru
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatcherCandidates(t *testing.T) {
|
||||||
|
d := newDispatcher()
|
||||||
|
|
||||||
|
d.addSubscription(subscription{
|
||||||
|
id: "...",
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9},
|
||||||
|
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
d.addSubscription(subscription{
|
||||||
|
id: "...",
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{11},
|
||||||
|
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
d.addSubscription(subscription{
|
||||||
|
id: "...",
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9, 11, 1111},
|
||||||
|
Tags: nostr.TagMap{"h": []string{"aaa"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
d.addSubscription(subscription{
|
||||||
|
id: "...",
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9, 11, 1111},
|
||||||
|
Tags: nostr.TagMap{"h": []string{"bbb"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
d.addSubscription(subscription{
|
||||||
|
id: "...",
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9, 11, 1111},
|
||||||
|
Authors: []nostr.PubKey{
|
||||||
|
nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
matched := 0
|
||||||
|
for range d.candidates(nostr.Event{
|
||||||
|
PubKey: nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||||
|
ID: nostr.MustIDFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
|
||||||
|
Kind: 9,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: "hello",
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"h", "aaa"},
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, 3, matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzDispatcherCandidates(f *testing.F) {
|
||||||
|
f.Add(1, 1, uint8(8), uint8(16))
|
||||||
|
f.Add(2, 3, uint8(32), uint8(32))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, seed int, advance int, ops uint8, checks uint8) {
|
||||||
|
d := newDispatcher()
|
||||||
|
state := fuzzState{value: seed, advance: advance}
|
||||||
|
|
||||||
|
active := make(map[int]subscription)
|
||||||
|
activeSSIDs := make([]int, 0, int(ops))
|
||||||
|
nextSubID := 0
|
||||||
|
|
||||||
|
steps := int(ops) + 1
|
||||||
|
for range steps {
|
||||||
|
if len(activeSSIDs) == 0 || state.next(10) != 0 {
|
||||||
|
nextSubID++
|
||||||
|
sub := subscription{
|
||||||
|
id: strconv.Itoa(nextSubID),
|
||||||
|
filter: fuzzDispatcherFilter(&state),
|
||||||
|
}
|
||||||
|
|
||||||
|
ssid := d.addSubscription(sub)
|
||||||
|
|
||||||
|
active[ssid] = sub
|
||||||
|
activeSSIDs = append(activeSSIDs, ssid)
|
||||||
|
} else {
|
||||||
|
idx := state.next(len(activeSSIDs))
|
||||||
|
ssid := activeSSIDs[idx]
|
||||||
|
|
||||||
|
d.removeSubscription(ssid)
|
||||||
|
|
||||||
|
delete(active, ssid)
|
||||||
|
activeSSIDs = append(activeSSIDs[:idx], activeSSIDs[idx+1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range int(checks%7) + 1 {
|
||||||
|
event := fuzzDispatcherEvent(&state)
|
||||||
|
|
||||||
|
expected := expectedDispatcherCandidates(active, event)
|
||||||
|
actual := collectedDispatcherCandidates(&d, event)
|
||||||
|
|
||||||
|
require.Equalf(t, expected, actual, "seed=%d advance=%d event=%s active=%v", seed, advance, event.String(), active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ssid := range activeSSIDs {
|
||||||
|
d.removeSubscription(ssid)
|
||||||
|
delete(active, ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Empty(t, collectedDispatcherCandidates(&d, fuzzDispatcherEvent(&state)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type fuzzState struct {
|
||||||
|
value int
|
||||||
|
advance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *fuzzState) next(n int) int {
|
||||||
|
if n <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value := state.value % n
|
||||||
|
if value < 0 {
|
||||||
|
value += n
|
||||||
|
}
|
||||||
|
state.value += state.advance
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDispatcherFilter(seed *fuzzState) nostr.Filter {
|
||||||
|
filter := nostr.Filter{
|
||||||
|
Authors: fuzzDispatcherAuthors(seed),
|
||||||
|
Kinds: fuzzDispatcherKinds(seed),
|
||||||
|
Tags: fuzzDispatcherTagMap(seed),
|
||||||
|
}
|
||||||
|
|
||||||
|
if seed.next(3) == 0 {
|
||||||
|
since := nostr.Timestamp(seed.next(6))
|
||||||
|
until := since + nostr.Timestamp(seed.next(6))
|
||||||
|
filter.Since = since
|
||||||
|
filter.Until = until
|
||||||
|
} else if seed.next(4) == 0 {
|
||||||
|
filter.Since = nostr.Timestamp(seed.next(8))
|
||||||
|
} else if seed.next(4) == 0 {
|
||||||
|
filter.Until = nostr.Timestamp(seed.next(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDispatcherAuthors(seed *fuzzState) []nostr.PubKey {
|
||||||
|
switch seed.next(4) {
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
case 1:
|
||||||
|
return []nostr.PubKey{}
|
||||||
|
}
|
||||||
|
|
||||||
|
count := seed.next(3) + 1
|
||||||
|
authors := make([]nostr.PubKey, 0, count)
|
||||||
|
for range count {
|
||||||
|
pk := nostr.PubKey{byte(seed.next(4) + 1)}
|
||||||
|
if !slices.Contains(authors, pk) {
|
||||||
|
authors = append(authors, pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authors
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDispatcherKinds(seed *fuzzState) []nostr.Kind {
|
||||||
|
switch seed.next(4) {
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
case 1:
|
||||||
|
return []nostr.Kind{}
|
||||||
|
}
|
||||||
|
|
||||||
|
count := seed.next(3) + 1
|
||||||
|
kinds := make([]nostr.Kind, 0, count)
|
||||||
|
for range count {
|
||||||
|
kind := nostr.Kind(seed.next(5) + 1)
|
||||||
|
if !slices.Contains(kinds, kind) {
|
||||||
|
kinds = append(kinds, kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kinds
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDispatcherTagMap(seed *fuzzState) nostr.TagMap {
|
||||||
|
if seed.next(3) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := []string{"e", "p", "t"}
|
||||||
|
values := []string{"a", "b", "c", "d"}
|
||||||
|
|
||||||
|
count := seed.next(3)
|
||||||
|
if count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make(nostr.TagMap, count)
|
||||||
|
start := seed.next(len(keys))
|
||||||
|
for i := range count {
|
||||||
|
idx := (start + i) % len(keys)
|
||||||
|
valueCount := seed.next(3) + 1
|
||||||
|
entries := make([]string, 0, valueCount)
|
||||||
|
for range valueCount {
|
||||||
|
value := values[seed.next(len(values))]
|
||||||
|
if !slices.Contains(entries, value) {
|
||||||
|
entries = append(entries, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags[keys[idx]] = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDispatcherEvent(seed *fuzzState) nostr.Event {
|
||||||
|
tags := make(nostr.Tags, 0, seed.next(4))
|
||||||
|
keys := []string{"e", "p", "t"}
|
||||||
|
values := []string{"a", "b", "c", "d"}
|
||||||
|
for range cap(tags) {
|
||||||
|
tags = append(tags, nostr.Tag{keys[seed.next(len(keys))], values[seed.next(len(values))]})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nostr.Event{
|
||||||
|
PubKey: nostr.PubKey{byte(seed.next(4) + 1)},
|
||||||
|
Kind: nostr.Kind(seed.next(5) + 1),
|
||||||
|
CreatedAt: nostr.Timestamp(seed.next(8)),
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedDispatcherCandidates(active map[int]subscription, event nostr.Event) []string {
|
||||||
|
ids := make([]string, 0, len(active))
|
||||||
|
for _, sub := range active {
|
||||||
|
if sub.filter.Matches(event) {
|
||||||
|
ids = append(ids, sub.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectedDispatcherCandidates(d *dispatcher, event nostr.Event) []string {
|
||||||
|
ids := make([]string, 0, d.subscriptions.Size())
|
||||||
|
for sub := range d.candidates(event) {
|
||||||
|
ids = append(ids, sub.id)
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
+11
-10
@@ -43,8 +43,8 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
relayPathMatches := true
|
relayPathMatches := true
|
||||||
if rl.ServiceURL != "" {
|
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
||||||
p, err := url.Parse(rl.ServiceURL)
|
p, err := url.Parse(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,6 +227,9 @@ 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
|
||||||
@@ -290,8 +293,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
ws.WriteJSON(resp)
|
ws.WriteJSON(resp)
|
||||||
|
|
||||||
case *nostr.ReqEnvelope:
|
case *nostr.ReqEnvelope:
|
||||||
eose := sync.WaitGroup{}
|
rl.removeListenerId(ws, env.SubscriptionID)
|
||||||
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)
|
||||||
@@ -301,7 +303,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, &eose, ws, filter)
|
err := rl.handleRequest(reqCtx, env.SubscriptionID, 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()
|
||||||
@@ -319,11 +321,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
|
||||||
// when all events have been loaded from databases and dispatched we can fire the EOSE message
|
|
||||||
eose.Wait()
|
|
||||||
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)
|
||||||
@@ -348,6 +346,9 @@ 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()})
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-64
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"iter"
|
"iter"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"fiatjaf.com/lib/set"
|
"fiatjaf.com/lib/set"
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -31,19 +32,27 @@ type subscription struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dispatcher struct {
|
type dispatcher struct {
|
||||||
serial int
|
serial int
|
||||||
subscriptions *xsync.MapOf[int, subscription]
|
subscriptions *xsync.MapOf[int, subscription]
|
||||||
byAuthor map[nostr.PubKey]set.Set[int]
|
byAuthor *xsync.MapOf[nostr.PubKey, set.Set[int]]
|
||||||
byKind map[nostr.Kind]set.Set[int]
|
byKind *xsync.MapOf[nostr.Kind, set.Set[int]]
|
||||||
fallback set.Set[int]
|
fallbackTags set.Set[int]
|
||||||
|
fallbackNothing set.Set[int]
|
||||||
|
}
|
||||||
|
|
||||||
|
var setPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return set.NewEmptySliceSetReusing[int](make([]int, 0, 10))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDispatcher() dispatcher {
|
func newDispatcher() dispatcher {
|
||||||
return dispatcher{
|
return dispatcher{
|
||||||
subscriptions: xsync.NewMapOf[int, subscription](),
|
subscriptions: xsync.NewMapOf[int, subscription](),
|
||||||
byAuthor: make(map[nostr.PubKey]set.Set[int]),
|
byAuthor: xsync.NewMapOf[nostr.PubKey, set.Set[int]](),
|
||||||
byKind: make(map[nostr.Kind]set.Set[int]),
|
byKind: xsync.NewMapOf[nostr.Kind, set.Set[int]](),
|
||||||
fallback: set.NewSliceSet[int](),
|
fallbackTags: setPool.Get().(set.Set[int]),
|
||||||
|
fallbackNothing: setPool.Get().(set.Set[int]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,93 +66,128 @@ func (d *dispatcher) addSubscription(sub subscription) int {
|
|||||||
if sub.filter.Authors != nil {
|
if sub.filter.Authors != nil {
|
||||||
indexed = true
|
indexed = true
|
||||||
for _, author := range sub.filter.Authors {
|
for _, author := range sub.filter.Authors {
|
||||||
s, ok := d.byAuthor[author]
|
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||||
if !ok {
|
if !loaded {
|
||||||
s = set.NewSliceSet[int]()
|
s = setPool.Get().(set.Set[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 {
|
||||||
s, ok := d.byKind[kind]
|
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||||
if !ok {
|
if !loaded {
|
||||||
s = set.NewSliceSet[int]()
|
s = setPool.Get().(set.Set[int])
|
||||||
d.byKind[kind] = s
|
}
|
||||||
}
|
s.Add(ssid)
|
||||||
s.Add(ssid)
|
return s, false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !indexed {
|
if !indexed {
|
||||||
d.fallback.Add(ssid)
|
if sub.filter.Tags != nil {
|
||||||
|
d.fallbackTags.Add(ssid)
|
||||||
|
} else {
|
||||||
|
d.fallbackNothing.Add(ssid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ssid
|
return ssid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dispatcher) removeSubscription(ssid int) {
|
func (d *dispatcher) removeSubscription(ssid int) nostr.Filter {
|
||||||
sub, ok := d.subscriptions.LoadAndDelete(ssid)
|
var filter nostr.Filter
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
indexed := false
|
d.subscriptions.Compute(ssid, func(sub subscription, loaded bool) (subscription, bool) {
|
||||||
if sub.filter.Authors != nil {
|
indexed := false
|
||||||
indexed = true
|
|
||||||
for _, author := range sub.filter.Authors {
|
filter = sub.filter
|
||||||
s, ok := d.byAuthor[author]
|
|
||||||
if !ok {
|
if sub.filter.Authors != nil {
|
||||||
return
|
indexed = true
|
||||||
}
|
for _, author := range sub.filter.Authors {
|
||||||
s.Remove(ssid)
|
d.byAuthor.Compute(author, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||||
if s.Len() == 0 {
|
if !loaded {
|
||||||
delete(d.byAuthor, author)
|
return s, true
|
||||||
|
}
|
||||||
|
s.Remove(ssid)
|
||||||
|
|
||||||
|
delete := s.Len() == 0
|
||||||
|
if delete {
|
||||||
|
setPool.Put(s)
|
||||||
|
}
|
||||||
|
return s, delete
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if sub.filter.Kinds != nil {
|
if sub.filter.Kinds != nil {
|
||||||
indexed = true
|
indexed = true
|
||||||
for _, kind := range sub.filter.Kinds {
|
for _, kind := range sub.filter.Kinds {
|
||||||
s, ok := d.byKind[kind]
|
d.byKind.Compute(kind, func(s set.Set[int], loaded bool) (set.Set[int], bool) {
|
||||||
if !ok {
|
if !loaded {
|
||||||
return
|
return s, true
|
||||||
}
|
}
|
||||||
s.Remove(ssid)
|
s.Remove(ssid)
|
||||||
if s.Len() == 0 {
|
|
||||||
delete(d.byKind, kind)
|
delete := s.Len() == 0
|
||||||
|
if delete {
|
||||||
|
setPool.Put(s)
|
||||||
|
}
|
||||||
|
return s, delete
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !indexed {
|
if !indexed {
|
||||||
d.fallback.Remove(ssid)
|
if sub.filter.Tags != nil {
|
||||||
}
|
d.fallbackTags.Remove(ssid)
|
||||||
|
} else {
|
||||||
|
d.fallbackNothing.Remove(ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub, true
|
||||||
|
})
|
||||||
|
|
||||||
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
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[event.PubKey]
|
authorSubs, hasAuthorSubs := d.byAuthor.Load(event.PubKey)
|
||||||
kindSubs, hasKindSubs := d.byKind[event.Kind]
|
kindSubs, hasKindSubs := d.byKind.Load(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) {
|
if kindSubs.Has(ssid) || sub.filter.Kinds == nil {
|
||||||
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() {
|
||||||
@@ -177,10 +221,21 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ssid := range d.fallback.Slice() {
|
if len(event.Tags) > 0 {
|
||||||
sub, _ := d.subscriptions.Load(ssid)
|
for _, ssid := range d.fallbackTags.Slice() {
|
||||||
|
sub, _ := d.subscriptions.Load(ssid)
|
||||||
|
|
||||||
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
|
||||||
|
if !yield(sub) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ssid := range d.fallbackNothing.Slice() {
|
||||||
|
sub, _ := d.subscriptions.Load(ssid)
|
||||||
|
if filterMatchesTimestampConstraints(sub.filter, event) {
|
||||||
if !yield(sub) {
|
if !yield(sub) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,7 +245,7 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//go:inline
|
//go:inline
|
||||||
func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.Event) bool {
|
func filterMatchesTimestampConstraints(filter nostr.Filter, event nostr.Event) bool {
|
||||||
if filter.Since != 0 && event.CreatedAt < filter.Since {
|
if filter.Since != 0 && event.CreatedAt < filter.Since {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -199,6 +254,15 @@ func filterMatchesTimestampConstraintsAndTags(filter nostr.Filter, event nostr.E
|
|||||||
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
|
||||||
@@ -247,6 +311,10 @@ func (rl *Relay) addListener(
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
sid: id,
|
sid: id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if rl.OnListenerAdded != nil {
|
||||||
|
rl.OnListenerAdded(ws, ssid, id, filter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +329,12 @@ func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
|
|||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
if spec.sid == id {
|
if spec.sid == id {
|
||||||
spec.cancel(ErrSubscriptionClosedByClient)
|
spec.cancel(ErrSubscriptionClosedByClient)
|
||||||
rl.dispatcher.removeSubscription(spec.ssid)
|
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
||||||
|
|
||||||
|
if rl.OnListenerRemoved != nil {
|
||||||
|
rl.OnListenerRemoved(ws, spec.ssid, id, filter)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, spec)
|
kept = append(kept, spec)
|
||||||
@@ -276,7 +349,11 @@ 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
|
||||||
rl.dispatcher.removeSubscription(spec.ssid)
|
filter := rl.dispatcher.removeSubscription(spec.ssid)
|
||||||
|
|
||||||
|
if rl.OnListenerRemoved != nil {
|
||||||
|
rl.OnListenerRemoved(ws, spec.ssid, spec.sid, filter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(rl.clients, ws)
|
delete(rl.clients, ws)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package khatru
|
package khatru
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -125,10 +124,7 @@ func FuzzRandomListenerIdRemoving(f *testing.F) {
|
|||||||
}
|
}
|
||||||
require.Equal(t, len(subs)+extra, ssidCount)
|
require.Equal(t, len(subs)+extra, ssidCount)
|
||||||
|
|
||||||
rand.Shuffle(len(subs), func(i, j int) {
|
for _, wsidToRemove := range moduloOrder(subs, int(utw+ubs+ualf+ualef)) {
|
||||||
subs[i], subs[j] = subs[j], subs[i]
|
|
||||||
})
|
|
||||||
for _, wsidToRemove := range subs {
|
|
||||||
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-8
@@ -1,7 +1,6 @@
|
|||||||
package khatru
|
package khatru
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -23,6 +22,18 @@ 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()
|
||||||
|
|
||||||
@@ -321,7 +332,7 @@ func TestRandomListenerClientRemoving(t *testing.T) {
|
|||||||
ws := websockets[i]
|
ws := websockets[i]
|
||||||
w := idFromSeqUpper(i)
|
w := idFromSeqUpper(i)
|
||||||
|
|
||||||
if rand.Intn(2) < 1 {
|
if (i+j)%2 == 0 {
|
||||||
l++
|
l++
|
||||||
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
|
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
|
||||||
}
|
}
|
||||||
@@ -374,12 +385,12 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
|||||||
ws := websockets[i]
|
ws := websockets[i]
|
||||||
w := idFromSeqUpper(i)
|
w := idFromSeqUpper(i)
|
||||||
|
|
||||||
if rand.Intn(2) < 1 {
|
if (i+j)%2 == 0 {
|
||||||
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 rand.Intn(5) < 1 {
|
if (i+j)%5 == 0 {
|
||||||
rl.addListener(ws, id, f, cancel)
|
rl.addListener(ws, id, f, cancel)
|
||||||
extra++
|
extra++
|
||||||
}
|
}
|
||||||
@@ -394,10 +405,7 @@ func TestRandomListenerIdRemoving(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.Equal(t, len(subs)+extra, ssidCount)
|
require.Equal(t, len(subs)+extra, ssidCount)
|
||||||
|
|
||||||
rand.Shuffle(len(subs), func(i, j int) {
|
for _, wsidToRemove := range moduloOrder(subs, 20) {
|
||||||
subs[i], subs[j] = subs[j], subs[i]
|
|
||||||
})
|
|
||||||
for _, wsidToRemove := range subs {
|
|
||||||
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ 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
|
||||||
Generic func(ctx context.Context, request nip86.Request) (nip86.Response, error)
|
Generic func(ctx context.Context, request nip86.Request) (nip86.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +335,46 @@ 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.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())
|
||||||
|
|||||||
+111
-5
@@ -2,6 +2,8 @@ package khatru
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"iter"
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"fiatjaf.com/lib/channelmutex"
|
"fiatjaf.com/lib/channelmutex"
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -68,10 +71,11 @@ 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) ([]nostr.Event, error)
|
ReplaceEvent func(ctx context.Context, 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)
|
||||||
@@ -81,6 +85,9 @@ 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
|
||||||
|
|
||||||
@@ -145,8 +152,9 @@ 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) ([]nostr.Event, error) {
|
rl.ReplaceEvent = func(ctx context.Context, event nostr.Event) error {
|
||||||
return store.ReplaceEvent(event)
|
_, err := 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)
|
||||||
@@ -165,8 +173,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 rl.ServiceURL != "" {
|
if serviceURL := rl.getServiceURL(r); serviceURL != "" {
|
||||||
return rl.ServiceURL
|
return serviceURL
|
||||||
}
|
}
|
||||||
|
|
||||||
host := r.Header.Get("X-Forwarded-Host")
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
@@ -191,6 +199,14 @@ 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()
|
||||||
@@ -203,6 +219,89 @@ 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
|
||||||
}
|
}
|
||||||
@@ -210,3 +309,10 @@ func (rl *Relay) Router() *http.ServeMux {
|
|||||||
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
||||||
rl.serveMux = mux
|
rl.serveMux = mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rl *Relay) WithServiceURL(serviceURL string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.WithValue(r.Context(), serviceURLOverrideKey, serviceURL)
|
||||||
|
rl.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package khatru
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,13 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func FuzzReplaceableEvents(f *testing.F) {
|
func FuzzReplaceableEvents(f *testing.F) {
|
||||||
f.Add(uint(1), uint(2))
|
f.Add(1, 1, uint(2))
|
||||||
|
|
||||||
f.Fuzz(func(t *testing.T, seed uint, nevents uint) {
|
f.Fuzz(func(t *testing.T, seed int, advance int, nevents uint) {
|
||||||
if nevents == 0 {
|
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()
|
||||||
@@ -67,12 +68,10 @@ 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(rnd.Int64() % math.MaxUint32)
|
evt.CreatedAt = nostr.Timestamp(state.next(math.MaxUint32))
|
||||||
evt.Sign(sk1)
|
evt.Sign(sk1)
|
||||||
err = client1.Publish(ctx, evt)
|
err = client1.Publish(ctx, evt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package khatru
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,9 +12,60 @@ 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()
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ 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, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) error {
|
func (rl *Relay) handleRequest(ctx context.Context, id string, 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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
int(-180)
|
||||||
|
int(92)
|
||||||
|
byte('{')
|
||||||
|
byte('\n')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
int(140)
|
||||||
|
int(-52)
|
||||||
|
byte('"')
|
||||||
|
byte('h')
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
go test fuzz v1
|
go test fuzz v1
|
||||||
uint(25)
|
int(25)
|
||||||
|
int(1)
|
||||||
uint(223)
|
uint(223)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const (
|
|||||||
subscriptionIdKey
|
subscriptionIdKey
|
||||||
nip86HeaderAuthKey
|
nip86HeaderAuthKey
|
||||||
internalCallKey
|
internalCallKey
|
||||||
|
serviceURLOverrideKey
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequestAuth(ctx context.Context) {
|
func RequestAuth(ctx context.Context) {
|
||||||
@@ -73,6 +74,13 @@ func IsAuthed(ctx context.Context, pubkey nostr.PubKey) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceSetAuthed modifies the context to insert a custom authed public key.
|
||||||
|
// It can be used in testing or other rare scenarios for making requests as if a given public key
|
||||||
|
// was authenticated when in fact it didn't perform any of the authentication rituals.
|
||||||
|
func ForceSetAuthed(ctx context.Context, pubkey nostr.PubKey) context.Context {
|
||||||
|
return context.WithValue(ctx, nip86HeaderAuthKey, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
|
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
|
||||||
// or expiration request.
|
// or expiration request.
|
||||||
func IsInternalCall(ctx context.Context) bool {
|
func IsInternalCall(ctx context.Context) bool {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ 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"
|
||||||
@@ -31,6 +34,13 @@ 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")
|
||||||
|
|||||||
@@ -38,10 +38,20 @@ 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:
|
||||||
@@ -52,12 +62,14 @@ 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 KindComment:
|
case KindPollResponse:
|
||||||
return "Comment"
|
return "PollResponse"
|
||||||
case KindBid:
|
case KindBid:
|
||||||
return "Bid"
|
return "Bid"
|
||||||
case KindBidConfirmation:
|
case KindBidConfirmation:
|
||||||
@@ -68,10 +80,26 @@ 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:
|
||||||
@@ -100,10 +128,24 @@ 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:
|
||||||
@@ -152,14 +194,24 @@ 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:
|
||||||
@@ -168,8 +220,26 @@ 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:
|
||||||
@@ -226,10 +296,20 @@ 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:
|
||||||
@@ -238,20 +318,14 @@ 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:
|
||||||
@@ -266,152 +340,232 @@ func (kind Kind) Name() string {
|
|||||||
return "HandlerRecommendation"
|
return "HandlerRecommendation"
|
||||||
case KindHandlerInformation:
|
case KindHandlerInformation:
|
||||||
return "HandlerInformation"
|
return "HandlerInformation"
|
||||||
case KindVideoEvent:
|
case KindSoftwareApplication:
|
||||||
return "VideoEvent"
|
return "SoftwareApplication"
|
||||||
case KindShortVideoEvent:
|
case KindLegacyNsiteFile:
|
||||||
return "ShortVideoEvent"
|
return "LegacyNsiteFile"
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
KindProfileMetadata Kind = 0
|
KindProfileMetadata Kind = 0
|
||||||
KindTextNote Kind = 1
|
KindTextNote Kind = 1
|
||||||
KindRecommendServer Kind = 2
|
KindRecommendServer Kind = 2
|
||||||
KindFollowList Kind = 3
|
KindFollowList Kind = 3
|
||||||
KindEncryptedDirectMessage Kind = 4
|
KindEncryptedDirectMessage Kind = 4
|
||||||
KindDeletion Kind = 5
|
KindDeletion Kind = 5
|
||||||
KindRepost Kind = 6
|
KindRepost Kind = 6
|
||||||
KindReaction Kind = 7
|
KindReaction Kind = 7
|
||||||
KindBadgeAward Kind = 8
|
KindBadgeAward Kind = 8
|
||||||
KindSimpleGroupChatMessage Kind = 9
|
KindSimpleGroupChatMessage Kind = 9
|
||||||
KindSimpleGroupThreadedReply Kind = 10
|
KindSimpleGroupThreadedReply Kind = 10
|
||||||
KindSimpleGroupThread Kind = 11
|
KindSimpleGroupThread Kind = 11
|
||||||
KindSimpleGroupReply Kind = 12
|
KindSimpleGroupReply Kind = 12
|
||||||
KindSeal Kind = 13
|
KindSeal Kind = 13
|
||||||
KindDirectMessage Kind = 14
|
KindDirectMessage Kind = 14
|
||||||
KindGenericRepost Kind = 16
|
KindFileMessage Kind = 15
|
||||||
KindReactionToWebsite Kind = 17
|
KindGenericRepost Kind = 16
|
||||||
KindChannelCreation Kind = 40
|
KindReactionToWebsite Kind = 17
|
||||||
KindChannelMetadata Kind = 41
|
KindPhoto Kind = 20
|
||||||
KindChannelMessage Kind = 42
|
KindNormalVideoEvent Kind = 21
|
||||||
KindChannelHideMessage Kind = 43
|
KindShortVideoEvent Kind = 22
|
||||||
KindChannelMuteUser Kind = 44
|
KindPublicMessage Kind = 24
|
||||||
KindChess Kind = 64
|
KindChannelCreation Kind = 40
|
||||||
KindMergeRequests Kind = 818
|
KindChannelMetadata Kind = 41
|
||||||
KindComment Kind = 1111
|
KindChannelMessage Kind = 42
|
||||||
KindBid Kind = 1021
|
KindChannelHideMessage Kind = 43
|
||||||
KindBidConfirmation Kind = 1022
|
KindChannelMuteUser Kind = 44
|
||||||
KindOpenTimestamps Kind = 1040
|
KindPodcastEpisode Kind = 54
|
||||||
KindGiftWrap Kind = 1059
|
KindChess Kind = 64
|
||||||
KindFileMetadata Kind = 1063
|
KindMergeRequests Kind = 818
|
||||||
KindLiveChatMessage Kind = 1311
|
KindPollResponse Kind = 1018
|
||||||
KindPatch Kind = 1617
|
KindBid Kind = 1021
|
||||||
KindIssue Kind = 1621
|
KindBidConfirmation Kind = 1022
|
||||||
KindReply Kind = 1622
|
KindOpenTimestamps Kind = 1040
|
||||||
KindStatusOpen Kind = 1630
|
KindGiftWrap Kind = 1059
|
||||||
KindStatusApplied Kind = 1631
|
KindFileMetadata Kind = 1063
|
||||||
KindStatusClosed Kind = 1632
|
KindPoll Kind = 1068
|
||||||
KindStatusDraft Kind = 1633
|
KindComment Kind = 1111
|
||||||
KindProblemTracker Kind = 1971
|
KindVoiceMessage Kind = 1222
|
||||||
KindReporting Kind = 1984
|
KindScroll Kind = 1227
|
||||||
KindLabel Kind = 1985
|
KindVoiceMessageComment Kind = 1244
|
||||||
KindRelayReviews Kind = 1986
|
KindLiveChatMessage Kind = 1311
|
||||||
KindAIEmbeddings Kind = 1987
|
KindCodeSnippet Kind = 1337
|
||||||
KindTorrent Kind = 2003
|
KindPatch Kind = 1617
|
||||||
KindTorrentComment Kind = 2004
|
KindGitPullRequest Kind = 1618
|
||||||
KindCoinjoinPool Kind = 2022
|
KindGitPullRequestUpdate Kind = 1619
|
||||||
KindCommunityPostApproval Kind = 4550
|
KindIssue Kind = 1621
|
||||||
KindJobFeedback Kind = 7000
|
KindReply Kind = 1622
|
||||||
KindSimpleGroupPutUser Kind = 9000
|
KindStatusOpen Kind = 1630
|
||||||
KindSimpleGroupRemoveUser Kind = 9001
|
KindStatusApplied Kind = 1631
|
||||||
KindSimpleGroupEditMetadata Kind = 9002
|
KindStatusClosed Kind = 1632
|
||||||
KindSimpleGroupDeleteEvent Kind = 9005
|
KindStatusDraft Kind = 1633
|
||||||
KindSimpleGroupCreateGroup Kind = 9007
|
KindProblemTracker Kind = 1971
|
||||||
KindSimpleGroupDeleteGroup Kind = 9008
|
KindReporting Kind = 1984
|
||||||
KindSimpleGroupCreateInvite Kind = 9009
|
KindLabel Kind = 1985
|
||||||
KindSimpleGroupJoinRequest Kind = 9021
|
KindRelayReviews Kind = 1986
|
||||||
KindSimpleGroupLeaveRequest Kind = 9022
|
KindAIEmbeddings Kind = 1987
|
||||||
KindZapGoal Kind = 9041
|
KindTorrent Kind = 2003
|
||||||
KindNutZap Kind = 9321
|
KindTorrentComment Kind = 2004
|
||||||
KindTidalLogin Kind = 9467
|
KindCoinjoinPool Kind = 2022
|
||||||
KindZapRequest Kind = 9734
|
KindDecoupledKeyClientAnnouncement Kind = 4454
|
||||||
KindZap Kind = 9735
|
KindDecoupledEncryptionKeyDistribution Kind = 4455
|
||||||
KindHighlights Kind = 9802
|
KindCommunityPostApproval Kind = 4550
|
||||||
KindMuteList Kind = 10000
|
KindJobFeedback Kind = 7000
|
||||||
KindPinList Kind = 10001
|
KindReservedCashuWalletTokens Kind = 7374
|
||||||
KindRelayListMetadata Kind = 10002
|
KindCashuWalletTokens Kind = 7375
|
||||||
KindBookmarkList Kind = 10003
|
KindCashuWalletHistory Kind = 7376
|
||||||
KindCommunityList Kind = 10004
|
KindGeocacheLog Kind = 7516
|
||||||
KindPublicChatList Kind = 10005
|
KindGeocacheProofOfFind Kind = 7517
|
||||||
KindBlockedRelayList Kind = 10006
|
KindSimpleGroupPutUser Kind = 9000
|
||||||
KindSearchRelayList Kind = 10007
|
KindSimpleGroupRemoveUser Kind = 9001
|
||||||
KindSimpleGroupList Kind = 10009
|
KindSimpleGroupEditMetadata Kind = 9002
|
||||||
KindInterestList Kind = 10015
|
KindSimpleGroupDeleteEvent Kind = 9005
|
||||||
KindNutZapInfo Kind = 10019
|
KindSimpleGroupCreateGroup Kind = 9007
|
||||||
KindEmojiList Kind = 10030
|
KindSimpleGroupDeleteGroup Kind = 9008
|
||||||
KindDMRelayList Kind = 10050
|
KindSimpleGroupCreateInvite Kind = 9009
|
||||||
KindUserServerList Kind = 10063
|
KindSimpleGroupJoinRequest Kind = 9021
|
||||||
KindFileStorageServerList Kind = 10096
|
KindSimpleGroupLeaveRequest Kind = 9022
|
||||||
KindGoodWikiAuthorList Kind = 10101
|
KindZapGoal Kind = 9041
|
||||||
KindGoodWikiRelayList Kind = 10102
|
KindNutZap Kind = 9321
|
||||||
KindNWCWalletInfo Kind = 13194
|
KindTidalLogin Kind = 9467
|
||||||
KindLightningPubRPC Kind = 21000
|
KindZapRequest Kind = 9734
|
||||||
KindClientAuthentication Kind = 22242
|
KindZap Kind = 9735
|
||||||
KindNWCWalletRequest Kind = 23194
|
KindHighlights Kind = 9802
|
||||||
KindNWCWalletResponse Kind = 23195
|
KindMuteList Kind = 10000
|
||||||
KindNostrConnect Kind = 24133
|
KindPinList Kind = 10001
|
||||||
KindBlobs Kind = 24242
|
KindRelayListMetadata Kind = 10002
|
||||||
KindHTTPAuth Kind = 27235
|
KindBookmarkList Kind = 10003
|
||||||
KindCategorizedPeopleList Kind = 30000
|
KindCommunityList Kind = 10004
|
||||||
KindCategorizedBookmarksList Kind = 30001
|
KindPublicChatList Kind = 10005
|
||||||
KindRelaySets Kind = 30002
|
KindBlockedRelayList Kind = 10006
|
||||||
KindBookmarkSets Kind = 30003
|
KindSearchRelayList Kind = 10007
|
||||||
KindCuratedSets Kind = 30004
|
KindSimpleGroupList Kind = 10009
|
||||||
KindCuratedVideoSets Kind = 30005
|
KindFavoriteRelaysList Kind = 10012
|
||||||
KindMuteSets Kind = 30007
|
KindPrivateEventRelayList Kind = 10013
|
||||||
KindProfileBadges Kind = 30008
|
KindInterestList Kind = 10015
|
||||||
KindBadgeDefinition Kind = 30009
|
KindNutZapInfo Kind = 10019
|
||||||
KindInterestSets Kind = 30015
|
KindMediaFollows Kind = 10020
|
||||||
KindStallDefinition Kind = 30017
|
KindEmojiList Kind = 10030
|
||||||
KindProductDefinition Kind = 30018
|
KindDecoupledKeyAnnouncement Kind = 10044
|
||||||
KindMarketplaceUI Kind = 30019
|
KindDMRelayList Kind = 10050
|
||||||
KindProductSoldAsAuction Kind = 30020
|
KindFavoritePodcasts Kind = 10054
|
||||||
KindArticle Kind = 30023
|
KindUserServerList Kind = 10063
|
||||||
KindDraftArticle Kind = 30024
|
KindFileStorageServerList Kind = 10096
|
||||||
KindEmojiSets Kind = 30030
|
KindGoodWikiAuthorList Kind = 10101
|
||||||
KindModularArticleHeader Kind = 30040
|
KindGoodWikiRelayList Kind = 10102
|
||||||
KindModularArticleContent Kind = 30041
|
KindPodcastMetadata Kind = 10154
|
||||||
KindReleaseArtifactSets Kind = 30063
|
KindAuthoredPodcasts Kind = 10164
|
||||||
KindApplicationSpecificData Kind = 30078
|
KindRelayMonitorAnnouncement Kind = 10166
|
||||||
KindLiveEvent Kind = 30311
|
KindRoomPresence Kind = 10312
|
||||||
KindUserStatuses Kind = 30315
|
KindUserGraspList Kind = 10317
|
||||||
KindClassifiedListing Kind = 30402
|
KindProxyAnnouncement Kind = 10377
|
||||||
KindDraftClassifiedListing Kind = 30403
|
KindTransportMethodAnnouncement Kind = 11111
|
||||||
KindRepositoryAnnouncement Kind = 30617
|
KindNWCWalletInfo Kind = 13194
|
||||||
KindRepositoryState Kind = 30618
|
KindNsiteRoot Kind = 15128
|
||||||
KindSimpleGroupMetadata Kind = 39000
|
KindCashuWalletEvent Kind = 17375
|
||||||
KindSimpleGroupAdmins Kind = 39001
|
KindLightningPubRPC Kind = 21000
|
||||||
KindSimpleGroupMembers Kind = 39002
|
KindClientAuthentication Kind = 22242
|
||||||
KindSimpleGroupRoles Kind = 39003
|
KindNWCWalletRequest Kind = 23194
|
||||||
KindSimpleGroupLiveKitParticipants Kind = 39004
|
KindNWCWalletResponse Kind = 23195
|
||||||
KindWikiArticle Kind = 30818
|
KindNostrConnect Kind = 24133
|
||||||
KindRedirects Kind = 30819
|
KindBlobs Kind = 24242
|
||||||
KindFeed Kind = 31890
|
KindHTTPAuth Kind = 27235
|
||||||
KindDateCalendarEvent Kind = 31922
|
KindCategorizedPeopleList Kind = 30000
|
||||||
KindTimeCalendarEvent Kind = 31923
|
KindCategorizedBookmarksList Kind = 30001
|
||||||
KindCalendar Kind = 31924
|
KindRelaySets Kind = 30002
|
||||||
KindCalendarEventRSVP Kind = 31925
|
KindBookmarkSets Kind = 30003
|
||||||
KindHandlerRecommendation Kind = 31989
|
KindCuratedSets Kind = 30004
|
||||||
KindHandlerInformation Kind = 31990
|
KindCuratedVideoSets Kind = 30005
|
||||||
KindVideoEvent Kind = 34235
|
KindMuteSets Kind = 30007
|
||||||
KindShortVideoEvent Kind = 34236
|
KindProfileBadges Kind = 30008
|
||||||
KindVideoViewEvent Kind = 34237
|
KindBadgeDefinition Kind = 30009
|
||||||
KindCommunityDefinition Kind = 34550
|
KindInterestSets Kind = 30015
|
||||||
|
KindStallDefinition Kind = 30017
|
||||||
|
KindProductDefinition Kind = 30018
|
||||||
|
KindMarketplaceUI Kind = 30019
|
||||||
|
KindProductSoldAsAuction Kind = 30020
|
||||||
|
KindArticle Kind = 30023
|
||||||
|
KindDraftArticle Kind = 30024
|
||||||
|
KindEmojiSets Kind = 30030
|
||||||
|
KindModularArticleHeader Kind = 30040
|
||||||
|
KindModularArticleContent Kind = 30041
|
||||||
|
KindReleaseArtifactSets Kind = 30063
|
||||||
|
KindApplicationSpecificData Kind = 30078
|
||||||
|
KindRelayDiscovery Kind = 30166
|
||||||
|
KindAppCurationSet Kind = 30267
|
||||||
|
KindLiveEvent Kind = 30311
|
||||||
|
KindInteractiveRoom Kind = 30312
|
||||||
|
KindConferenceEvent Kind = 30313
|
||||||
|
KindUserStatuses Kind = 30315
|
||||||
|
KindSlideSet Kind = 30388
|
||||||
|
KindClassifiedListing Kind = 30402
|
||||||
|
KindDraftClassifiedListing Kind = 30403
|
||||||
|
KindRepositoryAnnouncement Kind = 30617
|
||||||
|
KindRepositoryState Kind = 30618
|
||||||
|
KindWikiArticle Kind = 30818
|
||||||
|
KindRedirects Kind = 30819
|
||||||
|
KindDraftEvent Kind = 31234
|
||||||
|
KindLinkSet Kind = 31388
|
||||||
|
KindFeed Kind = 31890
|
||||||
|
KindDateCalendarEvent Kind = 31922
|
||||||
|
KindTimeCalendarEvent Kind = 31923
|
||||||
|
KindCalendar Kind = 31924
|
||||||
|
KindCalendarEventRSVP Kind = 31925
|
||||||
|
KindHandlerRecommendation Kind = 31989
|
||||||
|
KindHandlerInformation Kind = 31990
|
||||||
|
KindSoftwareApplication Kind = 32267
|
||||||
|
KindLegacyNsiteFile Kind = 34128
|
||||||
|
KindVideoViewEvent Kind = 34237
|
||||||
|
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 {
|
||||||
|
|||||||
+37
-16
@@ -8,11 +8,18 @@ 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
|
||||||
ID string
|
|
||||||
|
// Group identifier ("d"/"h" tag)
|
||||||
|
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 {
|
||||||
@@ -20,6 +27,10 @@ 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 != ""
|
||||||
}
|
}
|
||||||
@@ -28,14 +39,6 @@ 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
|
||||||
|
|
||||||
@@ -63,6 +66,12 @@ 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
|
||||||
|
|
||||||
@@ -130,15 +139,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(gadstr string) (Group, error) {
|
func NewGroup(relayHost, groupId string) (Group, error) {
|
||||||
gad, err := ParseGroupAddress(gadstr)
|
relayHost = nostr.NormalizeURL(relayHost)
|
||||||
if err != nil {
|
|
||||||
return Group{}, fmt.Errorf("invalid group id '%s': %w", gadstr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Group{
|
return Group{
|
||||||
Address: gad,
|
Address: GroupAddress{
|
||||||
Name: gad.ID,
|
Relay: relayHost,
|
||||||
|
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
|
||||||
@@ -203,6 +212,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +341,10 @@ 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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-108
@@ -79,40 +79,23 @@ 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.NameValue = &tag[1]
|
edit.Group.Name = 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.PictureValue = &tag[1]
|
edit.Group.Picture = tag[1]
|
||||||
if hasName {
|
|
||||||
edit.Replace = true
|
|
||||||
}
|
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
case "about":
|
case "about":
|
||||||
if len(tag) >= 2 {
|
if len(tag) >= 2 {
|
||||||
edit.AboutValue = &tag[1]
|
edit.Group.About = tag[1]
|
||||||
if hasName {
|
|
||||||
edit.Replace = true
|
|
||||||
}
|
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
case "supported_kinds":
|
case "supported_kinds":
|
||||||
@@ -124,54 +107,33 @@ var moderationActionFactories = map[nostr.Kind]func(nostr.Event) (Action, error)
|
|||||||
kinds = append(kinds, nostr.Kind(kind))
|
kinds = append(kinds, nostr.Kind(kind))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
edit.SupportedKindsValue = &kinds
|
edit.Group.SupportedKinds = kinds
|
||||||
edit.Replace = true
|
|
||||||
case "closed":
|
|
||||||
edit.ClosedValue = &y
|
|
||||||
if hasName {
|
|
||||||
edit.Replace = true
|
|
||||||
}
|
|
||||||
ok = true
|
ok = true
|
||||||
case "open":
|
case "closed":
|
||||||
edit.ClosedValue = &n
|
edit.Group.Closed = true
|
||||||
ok = true
|
ok = true
|
||||||
case "restricted":
|
case "restricted":
|
||||||
edit.RestrictedValue = &y
|
edit.Group.Restricted = true
|
||||||
if hasName {
|
|
||||||
edit.Replace = true
|
|
||||||
}
|
|
||||||
ok = true
|
|
||||||
case "unrestricted":
|
|
||||||
edit.RestrictedValue = &n
|
|
||||||
ok = true
|
ok = true
|
||||||
case "hidden":
|
case "hidden":
|
||||||
edit.HiddenValue = &y
|
edit.Group.Hidden = true
|
||||||
if hasName {
|
|
||||||
edit.Replace = true
|
|
||||||
}
|
|
||||||
ok = true
|
|
||||||
case "visible":
|
|
||||||
edit.HiddenValue = &n
|
|
||||||
ok = true
|
ok = true
|
||||||
case "private":
|
case "private":
|
||||||
edit.PrivateValue = &y
|
edit.Group.Private = true
|
||||||
if hasName {
|
ok = true
|
||||||
edit.Replace = true
|
case "parent":
|
||||||
|
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.LiveKitValue = &y
|
edit.Group.LiveKit = true
|
||||||
edit.Replace = true
|
|
||||||
ok = true
|
|
||||||
case "no-livekit":
|
|
||||||
edit.LiveKitValue = &n
|
|
||||||
ok = true
|
|
||||||
case "no-text":
|
|
||||||
edit.SupportedKindsValue = nil
|
|
||||||
ok = true
|
ok = true
|
||||||
|
case "child":
|
||||||
|
if len(tag) >= 2 {
|
||||||
|
edit.Group.Children = append(edit.Group.Children, tag[1])
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,63 +242,26 @@ func (a RemoveUser) Apply(group *Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditMetadata struct {
|
type EditMetadata struct {
|
||||||
NameValue *string
|
Group
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (_ EditMetadata) Name() string { return "edit-metadata" }
|
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
|
||||||
|
|
||||||
if a.Replace {
|
group.Name = a.Group.Name
|
||||||
group.Name = ""
|
group.Picture = a.Group.Picture
|
||||||
group.Picture = ""
|
group.About = a.Group.About
|
||||||
group.About = ""
|
group.Restricted = a.Group.Restricted
|
||||||
group.Restricted = false
|
group.Closed = a.Group.Closed
|
||||||
group.Closed = false
|
group.Hidden = a.Group.Hidden
|
||||||
group.Hidden = false
|
group.Private = a.Group.Private
|
||||||
group.Private = false
|
group.LiveKit = a.Group.LiveKit
|
||||||
group.LiveKit = false
|
group.SupportedKinds = a.Group.SupportedKinds
|
||||||
group.SupportedKinds = nil
|
group.Parent = a.Group.Parent
|
||||||
}
|
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
@@ -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()
|
||||||
|
|||||||
+36
-20
@@ -6,7 +6,9 @@ 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"
|
||||||
@@ -15,6 +17,12 @@ 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
|
||||||
|
|
||||||
@@ -56,6 +64,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -131,17 +140,19 @@ 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{
|
||||||
|
Tags: nostr.TagMap{"p": []string{clientPublicKey.Hex()}},
|
||||||
|
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||||
|
Since: now,
|
||||||
|
LimitZero: true,
|
||||||
|
}, nostr.SubscriptionOptions{
|
||||||
|
Label: "bunker46client",
|
||||||
|
})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
events := pool.SubscribeMany(cancellableCtx, relays, nostr.Filter{
|
|
||||||
Tags: nostr.TagMap{"p": []string{clientPublicKey.Hex()}},
|
|
||||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
|
||||||
Since: now,
|
|
||||||
LimitZero: true,
|
|
||||||
}, nostr.SubscriptionOptions{
|
|
||||||
Label: "bunker46client",
|
|
||||||
})
|
|
||||||
for ie := range events {
|
for ie := range events {
|
||||||
if ie.Kind != nostr.KindNostrConnect {
|
if ie.Kind != nostr.KindNostrConnect {
|
||||||
continue
|
continue
|
||||||
@@ -174,12 +185,15 @@ func NewBunker(
|
|||||||
|
|
||||||
// attempt switch_relays once every 10 times
|
// attempt switch_relays once every 10 times
|
||||||
if now%10 == 0 {
|
if now%10 == 0 {
|
||||||
if newRelays, _ := bunker.SwitchRelays(ctx); newRelays != nil {
|
swctx, cancel := context.WithTimeout(ctx, time.Second*3)
|
||||||
cancel()
|
if newRelays, _ := bunker.SwitchRelays(swctx); newRelays != nil {
|
||||||
bunker = NewBunker(ctx, clientSecretKey, targetPublicKey, newRelays, pool, func(string) {})
|
bunker = NewBunker(ctx, clientSecretKey, targetPublicKey, newRelays, pool, func(string) {})
|
||||||
}
|
}
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<-eosed
|
||||||
|
|
||||||
return bunker
|
return bunker
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +288,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)
|
id := bunker.idPrefix + "-" + strconv.FormatUint(bunker.serial.Add(1), 10) + "-" + method
|
||||||
req, err := json.Marshal(Request{
|
req, err := json.Marshal(Request{
|
||||||
ID: id,
|
ID: id,
|
||||||
Method: method,
|
Method: method,
|
||||||
@@ -303,21 +317,23 @@ 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 {
|
||||||
select {
|
relayConnectionWorkedO()
|
||||||
case relayConnectionWorked <- struct{}{}:
|
if err := relay.Publish(bunkerClientCtx, evt); err == nil {
|
||||||
default:
|
bunkerConnectionWorkedO()
|
||||||
}
|
|
||||||
if err := relay.Publish(ctx, evt); err == nil {
|
|
||||||
select {
|
|
||||||
case bunkerConnectionWorked <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}(url)
|
}(url)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package nip46
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -50,6 +52,50 @@ 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,
|
||||||
@@ -118,6 +164,19 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package nip5a
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizePath(p string) string {
|
||||||
|
if !strings.HasSuffix(p, ".html") && !strings.HasSuffix(p, "/") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(p, "/") {
|
||||||
|
return p + "index.html"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func PubKeyFromBase36(value string) (nostr.PubKey, error) {
|
||||||
|
bi, ok := new(big.Int).SetString(value, 36)
|
||||||
|
if !ok {
|
||||||
|
return nostr.ZeroPK, fmt.Errorf("invalid base36 pubkey")
|
||||||
|
}
|
||||||
|
buf := bi.Bytes()
|
||||||
|
if len(buf) > 32 {
|
||||||
|
return nostr.ZeroPK, fmt.Errorf("base36 pubkey too long")
|
||||||
|
}
|
||||||
|
var pk nostr.PubKey
|
||||||
|
copy(pk[32-len(buf):], buf)
|
||||||
|
return pk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PubKeyToBase36(pubkey nostr.PubKey) string {
|
||||||
|
value := new(big.Int).SetBytes(pubkey[:]).Text(36)
|
||||||
|
return strings.Repeat("0", 50-len(value)) + value
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
)
|
)
|
||||||
@@ -240,6 +241,95 @@ 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 "stats":
|
case "stats":
|
||||||
return Stats{}, nil
|
return Stats{}, nil
|
||||||
default:
|
default:
|
||||||
@@ -247,6 +337,19 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
type MethodParams interface {
|
type MethodParams interface {
|
||||||
MethodName() string
|
MethodName() string
|
||||||
}
|
}
|
||||||
@@ -276,6 +379,11 @@ 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 = (*Stats)(nil)
|
_ MethodParams = (*Stats)(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -415,6 +523,46 @@ 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 Stats struct{}
|
type Stats struct{}
|
||||||
|
|
||||||
func (Stats) MethodName() string { return "stats" }
|
func (Stats) MethodName() string { return "stats" }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package blossom_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -18,6 +20,18 @@ 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")
|
||||||
@@ -78,7 +92,10 @@ func TestBlossomBasicOperations(t *testing.T) {
|
|||||||
t.Fatalf("Expected 1 blob, got %d", len(blobs))
|
t.Fatalf("Expected 1 blob, got %d", len(blobs))
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := blobs[0].SHA256
|
hash, err := hexTo32(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)
|
||||||
@@ -173,8 +190,12 @@ 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, hash)
|
downloaded, err := client2.Download(ctx, hash32)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -203,8 +224,12 @@ 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, bd.SHA256)
|
downloaded, err := client2Server.Download(ctx, hash32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to download from server2: %v", err)
|
t.Fatalf("Failed to download from server2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -246,13 +271,21 @@ func TestBlossomBasicOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verify the mirrored blob can be downloaded
|
// verify the mirrored blob can be downloaded
|
||||||
downloaded, err := client2Server.Download(ctx, bd.SHA256)
|
hash32, err := hexTo32(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, blobs1[0].SHA256)
|
originalDownloaded, err := client2.Download(ctx, hash32)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -28,6 +29,18 @@ 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
|
||||||
@@ -35,11 +48,7 @@ func createHTTPClient() *fasthttp.Client {
|
|||||||
|
|
||||||
Name: "nl-b", // user-agent
|
Name: "nl-b", // user-agent
|
||||||
|
|
||||||
// increase DNS cache time to an hour instead of default minute
|
Dial: dialFunc,
|
||||||
Dial: (&fasthttp.TCPDialer{
|
|
||||||
Concurrency: 4096,
|
|
||||||
DNSCacheDuration: time.Hour,
|
|
||||||
}).Dial,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package blossom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,19 +12,17 @@ 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 string) ([]byte, error) {
|
func (c *Client) Download(ctx context.Context, hash [32]byte) ([]byte, error) {
|
||||||
if !nostr.IsValid32ByteHex(hash) {
|
hhash := hex.EncodeToString(hash[:])
|
||||||
return nil, fmt.Errorf("%s is not a valid 32-byte hex string", hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+hhash, 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", hash})
|
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
|
||||||
})
|
})
|
||||||
req.Header.Add("Authorization", authHeader)
|
req.Header.Add("Authorization", authHeader)
|
||||||
|
|
||||||
@@ -41,19 +40,17 @@ func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DownloadToFile downloads a file from the media server and saves it to the specified path
|
// DownloadToFile downloads a file from the media server and saves it to the specified path
|
||||||
func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error {
|
func (c *Client) DownloadToFile(ctx context.Context, hash [32]byte, filePath string) error {
|
||||||
if !nostr.IsValid32ByteHex(hash) {
|
hhash := hex.EncodeToString(hash[:])
|
||||||
return fmt.Errorf("%s is not a valid 32-byte hex string", hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+hhash, 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", hash})
|
evt.Tags = append(evt.Tags, nostr.Tag{"x", hhash})
|
||||||
})
|
})
|
||||||
req.Header.Add("Authorization", authHeader)
|
req.Header.Add("Authorization", authHeader)
|
||||||
|
|
||||||
|
|||||||
+114
-25
@@ -2,37 +2,109 @@ package blossom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mime"
|
"mime"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetExtension(mimetype string) string {
|
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 == "" {
|
if mimetype == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// hardcode some common cases (abd jbiwb oribkenatuc cases kuje ,ogg/.oga or .mov/.moov)
|
base, _, err := mime.ParseMediaType(mimetype)
|
||||||
switch mimetype {
|
if err == nil {
|
||||||
case "image/jpeg":
|
return strings.ToLower(base)
|
||||||
return ".jpg"
|
}
|
||||||
case "image/gif":
|
|
||||||
return ".gif"
|
if idx := strings.IndexByte(mimetype, ';'); idx >= 0 {
|
||||||
case "image/png":
|
mimetype = mimetype[:idx]
|
||||||
return ".png"
|
}
|
||||||
case "image/webp":
|
|
||||||
return ".webp"
|
return strings.ToLower(strings.TrimSpace(mimetype))
|
||||||
case "video/mp4":
|
}
|
||||||
return ".mp4"
|
|
||||||
case "application/vnd.android.package-archive":
|
func GetExtension(mimetype string) string {
|
||||||
return ".apk"
|
mimetype = normalizeMIMEType(mimetype)
|
||||||
case "video/quicktime":
|
if mimetype == "" {
|
||||||
return ".mov"
|
return ""
|
||||||
case "application/vnd.sqlite3":
|
}
|
||||||
return "sqlite3"
|
|
||||||
case "text/markdown":
|
if ext, ok := commonMimeExtensions[mimetype]; ok {
|
||||||
return "md"
|
return ext
|
||||||
case "audio/midi":
|
|
||||||
return "midi"
|
|
||||||
case "audio/x-aiff":
|
|
||||||
return "aiff"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exts, _ := mime.ExtensionsByType(mimetype)
|
exts, _ := mime.ExtensionsByType(mimetype)
|
||||||
@@ -42,3 +114,20 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -313,7 +313,9 @@ 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)
|
||||||
|
|||||||
@@ -24,16 +24,30 @@ 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
|
|
||||||
|
|
||||||
EventMiddleware func(RelayEvent)
|
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
|
||||||
|
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
|
||||||
|
// with the "auth-required:" prefix, only once for each relay
|
||||||
|
AuthRequiredHandler func(context.Context, *Event) error
|
||||||
|
|
||||||
|
// EventMiddleware is a function that will be called with all events received.
|
||||||
|
EventMiddleware func(RelayEvent)
|
||||||
|
|
||||||
|
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
|
||||||
DuplicateMiddleware func(relay string, id ID)
|
DuplicateMiddleware func(relay string, id ID)
|
||||||
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
|
||||||
RelayOptions RelayOptions
|
// AuthorKindQueryMiddleware is a function that will be called with every combination of
|
||||||
|
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
|
||||||
|
// contains a pubkey and a kind).
|
||||||
|
QueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
||||||
|
|
||||||
|
// RelayOptions are any options that should be passed to Relays instantiated by this pool
|
||||||
|
RelayOptions RelayOptions
|
||||||
|
|
||||||
// custom things not often used
|
// 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.
|
||||||
@@ -60,39 +74,20 @@ func NewPool() *Pool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolOptions struct {
|
|
||||||
// AuthRequiredHandler, if given, must be a function that signs the auth event when called.
|
|
||||||
// it will be called whenever any relay in the pool returns a `CLOSED` or `OK` message
|
|
||||||
// with the "auth-required:" prefix, only once for each relay
|
|
||||||
AuthRequiredHandler func(context.Context, *Event) error
|
|
||||||
|
|
||||||
// PenaltyBox just sets the penalty box mechanism so relays that fail to connect
|
|
||||||
// or that disconnect will be ignored for a while and we won't attempt to connect again.
|
|
||||||
PenaltyBox bool
|
|
||||||
|
|
||||||
// EventMiddleware is a function that will be called with all events received.
|
|
||||||
EventMiddleware func(RelayEvent)
|
|
||||||
|
|
||||||
// DuplicateMiddleware is a function that will be called with all duplicate ids received.
|
|
||||||
DuplicateMiddleware func(relay string, id ID)
|
|
||||||
|
|
||||||
// AuthorKindQueryMiddleware is a function that will be called with every combination of
|
|
||||||
// relay+pubkey+kind queried in a .SubscribeMany*() call -- when applicable (i.e. when the query
|
|
||||||
// contains a pubkey and a kind).
|
|
||||||
AuthorKindQueryMiddleware func(relay string, pubkey PubKey, kind Kind)
|
|
||||||
|
|
||||||
// RelayOptions are any options that should be passed to Relays instantiated by this pool
|
|
||||||
RelayOptions RelayOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pool *Pool) StartPenaltyBox() {
|
func (pool *Pool) StartPenaltyBox() {
|
||||||
|
if pool.penaltyBoxCancel != nil {
|
||||||
|
pool.penaltyBoxCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(pool.Context)
|
||||||
|
pool.penaltyBoxCancel = cancel
|
||||||
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 <-pool.Context.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(time.Duration(sleep) * time.Second):
|
case <-time.After(time.Duration(sleep) * time.Second):
|
||||||
|
|
||||||
@@ -118,6 +113,15 @@ 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) {
|
||||||
@@ -155,7 +159,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, 30.0 + math.Pow(2, v[0]+1)}, false
|
return [2]float64{v[0] + 1, math.Min(600.0, 30.0*math.Pow(1.5, 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
|
||||||
}
|
}
|
||||||
@@ -207,9 +211,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}
|
||||||
@@ -394,9 +398,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
|
||||||
@@ -570,9 +574,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 {
|
||||||
@@ -677,9 +681,9 @@ func (pool *Pool) subManyEose(
|
|||||||
case <-sub.EndOfStoredEvents:
|
case <-sub.EndOfStoredEvents:
|
||||||
return
|
return
|
||||||
case reason := <-sub.ClosedReason:
|
case reason := <-sub.ClosedReason:
|
||||||
if strings.HasPrefix(reason, "auth-required:") && pool.authRequiredHandler != nil && !hasAuthed {
|
if strings.HasPrefix(reason, "auth-required:") && pool.AuthRequiredHandler != nil && !hasAuthed {
|
||||||
// relay is requesting auth. if we can we will perform auth and try again
|
// relay is requesting auth. if we can we will perform auth and try again
|
||||||
err := relay.Auth(ctx, pool.authRequiredHandler)
|
err := relay.Auth(ctx, pool.AuthRequiredHandler)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
hasAuthed = true // so we don't keep doing AUTH again and again
|
hasAuthed = true // so we don't keep doing AUTH again and again
|
||||||
if closedChan != nil {
|
if closedChan != nil {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ 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() {
|
||||||
@@ -147,6 +148,9 @@ 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.
|
||||||
@@ -490,11 +494,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
-49
@@ -18,7 +18,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/ffa18bf6fb5496d755b465b062e18c676df1a5d4/schema.yaml"
|
const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/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,64 +39,74 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
var schema Schema
|
return NewSchemaFromBytes(body)
|
||||||
if err := yaml.Unmarshal(body, &schema); err != nil {
|
|
||||||
return Schema{}, fmt.Errorf("failed to parse schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Schema struct {
|
type Schema struct {
|
||||||
GenericTags map[string]ContentSpec `yaml:"generic_tags"`
|
GenericTags map[string]ContentSpec `yaml:"generic_tags" json:"generic_tags,omitempty"`
|
||||||
Kinds map[string]KindSchema `yaml:"kinds"`
|
Kinds map[string]*KindSchema `yaml:"kinds" json:"kinds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KindSchema struct {
|
type KindSchema struct {
|
||||||
Description string `yaml:"description"`
|
Kind nostr.Kind `yaml:"-" json:"kind"`
|
||||||
InUse bool `yaml:"in_use"`
|
Description string `yaml:"description" json:"description,omitempty"`
|
||||||
Content ContentSpec `yaml:"content"`
|
InUse bool `yaml:"in_use" json:"in_use,omitempty"`
|
||||||
Required []string `yaml:"required"`
|
Content ContentSpec `yaml:"content" json:"content,omitempty"`
|
||||||
Multiple []string `yaml:"multiple"`
|
Required []string `yaml:"required" json:"required,omitempty"`
|
||||||
Tags []TagSpec `yaml:"tags"`
|
Multiple []string `yaml:"multiple" json:"multiple,omitempty"`
|
||||||
|
Tags []TagSpec `yaml:"tags" json:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagSpec struct {
|
type TagSpec struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name,omitempty"`
|
||||||
Prefix string `yaml:"prefix"`
|
Prefix string `yaml:"prefix" json:"prefix,omitempty"`
|
||||||
Next *ContentSpec `yaml:"next"`
|
Next *ContentSpec `yaml:"next" json:"next,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentSpec struct {
|
type ContentSpec struct {
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type" json:"type,omitempty"`
|
||||||
Required bool `yaml:"required"`
|
Required bool `yaml:"required" json:"required,omitempty"`
|
||||||
Min int `yaml:"min"`
|
Min int `yaml:"min" json:"min,omitempty"`
|
||||||
Max int `yaml:"max"`
|
Max int `yaml:"max" json:"max,omitempty"`
|
||||||
Either []string `yaml:"either"`
|
Either []string `yaml:"either" json:"either,omitempty"`
|
||||||
Next *ContentSpec `yaml:"next"`
|
Next *ContentSpec `yaml:"next" json:"next,omitempty"`
|
||||||
Variadic bool `yaml:"variadic"`
|
Variadic bool `yaml:"variadic" json:"variadic,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Validator struct {
|
type Validator struct {
|
||||||
Schema Schema
|
Schema Schema `json:"schema,omitempty"`
|
||||||
FailOnUnknownKind bool
|
FailOnUnknownKind bool `json:"fail_on_unknown_kind,omitempty"`
|
||||||
FailOnUnknownType bool
|
FailOnUnknownType bool `json:"fail_on_unknown_type,omitempty"`
|
||||||
TypeValidators map[string]func(value string, spec *ContentSpec) error
|
TypeValidators map[string]func(value string, spec *ContentSpec) error `json:"type_validators,omitempty"`
|
||||||
UnknownTypes []string
|
UnknownTypes []string `json:"unknown_types,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
|
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
|
||||||
schema := Schema{
|
schema, err := NewSchemaFromBytes(schemaData)
|
||||||
GenericTags: make(map[string]ContentSpec),
|
if err != nil {
|
||||||
Kinds: make(map[string]KindSchema),
|
return Validator{}, err
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
|
||||||
return Validator{}, fmt.Errorf("failed to parse schema: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewValidatorFromSchema(schema), nil
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := range schema.Kinds {
|
||||||
|
kn, _ := strconv.ParseUint(k, 10, 16)
|
||||||
|
schema.Kinds[k].Kind = nostr.Kind(kn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewValidatorFromSchema(sch Schema) Validator {
|
func NewValidatorFromSchema(sch Schema) Validator {
|
||||||
validator := Validator{
|
validator := Validator{
|
||||||
Schema: sch,
|
Schema: sch,
|
||||||
@@ -185,11 +195,19 @@ 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 Validator{}, fmt.Errorf("failed to read schema file: %w", err)
|
return Schema{}, fmt.Errorf("failed to read schema file: %w", err)
|
||||||
}
|
}
|
||||||
return NewValidatorFromBytes(data)
|
return NewSchemaFromBytes(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValidatorFromURL(schemaURL string) (Validator, error) {
|
func NewValidatorFromURL(schemaURL string) (Validator, error) {
|
||||||
@@ -212,7 +230,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UnknownTypes struct {
|
type UnknownTypes struct {
|
||||||
Types []string
|
Types []string `json:"types,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ut UnknownTypes) Error() string {
|
func (ut UnknownTypes) Error() string {
|
||||||
@@ -220,7 +238,7 @@ func (ut UnknownTypes) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContentError struct {
|
type ContentError struct {
|
||||||
Err error
|
Err error `json:"err,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ce ContentError) Error() string {
|
func (ce ContentError) Error() string {
|
||||||
@@ -228,9 +246,9 @@ func (ce ContentError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TagError struct {
|
type TagError struct {
|
||||||
Tag int
|
Tag int `json:"tag,omitempty"`
|
||||||
Item int
|
Item int `json:"item,omitempty"`
|
||||||
Err error
|
Err error `json:"err,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (te TagError) Error() string {
|
func (te TagError) Error() string {
|
||||||
@@ -238,7 +256,7 @@ func (te TagError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RequiredTagError struct {
|
type RequiredTagError struct {
|
||||||
Missing []string
|
Missing []string `json:"missing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rte RequiredTagError) Error() string {
|
func (rte RequiredTagError) Error() string {
|
||||||
@@ -246,10 +264,6 @@ 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 {
|
||||||
@@ -389,7 +403,9 @@ func (v *Validator) validateNext(tag nostr.Tag, index int, this *ContentSpec) (f
|
|||||||
return index, ErrDanglingSpace
|
return index, ErrDanglingSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
if validator, ok := v.TypeValidators[this.Type]; ok {
|
if tag[index] == "" && !this.Required {
|
||||||
|
// 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
@@ -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.SaveEvent(*v.Event)
|
sys.Store.ReplaceEvent(*v.Event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// save cache even if we didn't get anything
|
// save cache even if we didn't get anything
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
cache_memory "fiatjaf.com/nostr/sdk/cache/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlossomURL string
|
||||||
|
|
||||||
|
func (r BlossomURL) Value() string { return string(r) }
|
||||||
|
|
||||||
|
func (sys *System) FetchBlossomServerList(ctx context.Context, pubkey nostr.PubKey) GenericList[string, BlossomURL] {
|
||||||
|
sys.blossomServerListCacheOnce.Do(func() {
|
||||||
|
if sys.BlossomServerListCache == nil {
|
||||||
|
sys.BlossomServerListCache = cache_memory.New[GenericList[string, BlossomURL]](1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ml, _ := fetchGenericList(sys, ctx, pubkey, 10101, kind_10101, func(t nostr.Tag) (BlossomURL, bool) {
|
||||||
|
if len(t) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
nm, err := nostr.NormalizeHTTPURL(t[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlossomURL(nm), true
|
||||||
|
}, sys.BlossomServerListCache)
|
||||||
|
return ml
|
||||||
|
}
|
||||||
+1
-1
@@ -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.SaveEvent(*pm.Event)
|
sys.Store.ReplaceEvent(*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)
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ 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
|
||||||
|
|||||||
+1
-3
@@ -22,9 +22,7 @@ func IsVirtualRelay(url string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !testing.Testing() &&
|
if !testing.Testing() && (strings.HasPrefix(url, "ws://localhost") || strings.HasPrefix(url, "ws://127.0.0.1")) {
|
||||||
strings.HasPrefix(url, "ws://localhost") ||
|
|
||||||
strings.HasPrefix(url, "ws://127.0.0.1") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-36
@@ -19,18 +19,17 @@ 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 = 8
|
const wotCallsSize = 16
|
||||||
|
|
||||||
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, error) {
|
func (sys *System) LoadWoTFilter(ctx context.Context, pubkey nostr.PubKey) WotXorFilter {
|
||||||
id := PubKeyToShid(pubkey)
|
id := PubKeyToShid(pubkey)
|
||||||
pos := int(id % wotCallsSize)
|
pos := int(id % wotCallsSize)
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ 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
|
||||||
@@ -54,17 +52,13 @@ 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
|
// there is already a call for this exact pubkey ongoing, so we just wait and copy the results
|
||||||
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, nil
|
return res
|
||||||
case err := <-errch:
|
|
||||||
return WotXorFilter{}, err
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wc.mutex.Unlock()
|
wc.mutex.Unlock()
|
||||||
@@ -76,18 +70,11 @@ start:
|
|||||||
|
|
||||||
actualcall:
|
actualcall:
|
||||||
var res WotXorFilter
|
var res WotXorFilter
|
||||||
m, err := sys.loadWoT(ctx, pubkey)
|
m := sys.loadWoT(ctx, pubkey)
|
||||||
if err != nil {
|
res = makeWoTFilter(m)
|
||||||
wc.mutex.Lock()
|
wc.mutex.Lock()
|
||||||
for _, ch := range wc.errorbacks {
|
for _, ch := range wc.resultbacks {
|
||||||
ch <- err
|
ch <- res
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = makeWoTFilter(m)
|
|
||||||
wc.mutex.Lock()
|
|
||||||
for _, ch := range wc.resultbacks {
|
|
||||||
ch <- res
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wotCallsMutex.Lock()
|
wotCallsMutex.Lock()
|
||||||
@@ -96,23 +83,17 @@ actualcall:
|
|||||||
close(wc.done)
|
close(wc.done)
|
||||||
wotCallsMutex.Unlock()
|
wotCallsMutex.Unlock()
|
||||||
|
|
||||||
return res, err
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) (chan nostr.PubKey, error) {
|
func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) chan nostr.PubKey {
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.SetLimit(45)
|
g.SetLimit(45)
|
||||||
|
|
||||||
res := make(chan nostr.PubKey)
|
res := make(chan nostr.PubKey)
|
||||||
|
|
||||||
// process follow lists
|
g.Go(func() error {
|
||||||
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
|
||||||
|
|
||||||
@@ -123,20 +104,19 @@ func (sys *System) loadWoT(ctx context.Context, pubkey nostr.PubKey) (chan nostr
|
|||||||
for _, f2 := range ff {
|
for _, f2 := range ff {
|
||||||
res <- f2.Pubkey
|
res <- f2.Pubkey
|
||||||
}
|
}
|
||||||
wg.Done()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Done()
|
return nil
|
||||||
}()
|
})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
wg.Wait()
|
g.Wait()
|
||||||
close(res)
|
close(res)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return res, nil
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
|
func makeWoTFilter(m chan nostr.PubKey) WotXorFilter {
|
||||||
|
|||||||
+8
-16
@@ -14,8 +14,7 @@ func TestLoadWoT(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
// test with fiatjaf's pubkey
|
// test with fiatjaf's pubkey
|
||||||
wotch, err := sys.loadWoT(ctx, nostr.MustPubKeyFromHex("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))
|
wotch := 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)
|
||||||
@@ -60,8 +59,7 @@ func TestLoadWoTManyPeople(t *testing.T) {
|
|||||||
|
|
||||||
// these are the same pubkey
|
// these are the same pubkey
|
||||||
go func() {
|
go func() {
|
||||||
rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
|
rabble := 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()
|
||||||
@@ -69,8 +67,7 @@ func TestLoadWoTManyPeople(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(time.Millisecond * 20)
|
time.Sleep(time.Millisecond * 20)
|
||||||
go func() {
|
go func() {
|
||||||
rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
|
rabble := 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()
|
||||||
@@ -78,8 +75,7 @@ func TestLoadWoTManyPeople(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(time.Millisecond * 20)
|
time.Sleep(time.Millisecond * 20)
|
||||||
go func() {
|
go func() {
|
||||||
rabble, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"))
|
rabble := 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()
|
||||||
@@ -88,8 +84,7 @@ 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, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7"))
|
alex := 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()
|
||||||
@@ -97,8 +92,7 @@ func TestLoadWoTManyPeople(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(time.Millisecond * 20)
|
time.Sleep(time.Millisecond * 20)
|
||||||
go func() {
|
go func() {
|
||||||
alex, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7"))
|
alex := 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()
|
||||||
@@ -106,16 +100,14 @@ func TestLoadWoTManyPeople(t *testing.T) {
|
|||||||
|
|
||||||
// these are independent
|
// these are independent
|
||||||
go func() {
|
go func() {
|
||||||
hodlbod, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"))
|
hodlbod := 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, err := sys.LoadWoTFilter(ctx, nostr.MustPubKeyFromHex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49"))
|
mikedilger := 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()
|
||||||
|
|||||||
+4
-7
@@ -3,8 +3,6 @@
|
|||||||
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"
|
||||||
@@ -34,8 +32,8 @@ func (evt Event) VerifySignature() bool {
|
|||||||
sig := schnorr.NewSignature(&r, &s)
|
sig := schnorr.NewSignature(&r, &s)
|
||||||
|
|
||||||
// check signature
|
// check signature
|
||||||
hash := sha256.Sum256(evt.Serialize())
|
evt.SetID()
|
||||||
return sig.Verify(hash[:], pubkey)
|
return sig.Verify(evt.ID[:], pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign signs an event with a given privateKey.
|
// Sign signs an event with a given privateKey.
|
||||||
@@ -52,13 +50,12 @@ func (evt *Event) Sign(secretKey [32]byte) error {
|
|||||||
pkBytes := pk.SerializeCompressed()[1:]
|
pkBytes := pk.SerializeCompressed()[1:]
|
||||||
evt.PubKey = PubKey(pkBytes)
|
evt.PubKey = PubKey(pkBytes)
|
||||||
|
|
||||||
h := sha256.Sum256(evt.Serialize())
|
evt.SetID()
|
||||||
sig, err := schnorr.Sign(sk, h[:], schnorr.FastSign())
|
sig, err := schnorr.Sign(sk, evt.ID[:], schnorr.FastSign())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
evt.ID = h
|
|
||||||
sigb := sig.Serialize()
|
sigb := sig.Serialize()
|
||||||
evt.Sig = [64]byte(sigb)
|
evt.Sig = [64]byte(sigb)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import "C"
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
"errors"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -33,14 +32,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (evt Event) VerifySignature() bool {
|
func (evt Event) VerifySignature() bool {
|
||||||
msg := sha256.Sum256(evt.Serialize())
|
evt.SetID()
|
||||||
|
|
||||||
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(&msg[0])), 32, &xonly)
|
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), 32, &xonly)
|
||||||
return res == 1
|
return res == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,16 +58,14 @@ func (evt *Event) Sign(secretKey [32]byte, signOpts ...schnorr.SignOption) error
|
|||||||
C.secp256k1_keypair_xonly_pub(globalSecp256k1Context, &xonly, nil, &keypair)
|
C.secp256k1_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)
|
||||||
|
|
||||||
h := sha256.Sum256(evt.Serialize())
|
evt.SetID()
|
||||||
|
|
||||||
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(&h[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
|
if C.secp256k1_schnorrsig_sign32(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
|
||||||
return errors.New("failed to sign message")
|
return errors.New("failed to sign message")
|
||||||
}
|
}
|
||||||
|
|
||||||
evt.ID = h
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,3 +124,19 @@ func (tags Tags) ContainsAny(tagName string, values []string) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tags Tags) Eq(other Tags) bool {
|
||||||
|
if len(tags) != len(other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tag := range tags {
|
||||||
|
otherTag := other[i]
|
||||||
|
|
||||||
|
if !slices.Equal(tag, otherTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+500
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user