Compare commits

..

72 Commits

Author SHA1 Message Date
Anthony Accioly
668c41b988 fix(blossom): handle nil BlobDescriptor in Get and Delete
Refine extension derivation logic by ensuring `bd` is not nil before
accessing its type.
2025-09-08 16:06:24 -03:00
Anthony Accioly
3c802caff5 feat(blossom)!: add file extension parameter to Blossom hooks
This enhancement allows hooks to make extension-aware decisions for storage,
loading, and access control operations. The extension is intelligently derived
from MIME type detection, file magic bytes, or URL parsing.

Additionally improves MIME type handling by providing fallback to
"application/octet-stream" when extension-based detection fails as per
BUD-01.

BREAKING CHANGE: All Blossom hook functions now require an additional `ext` parameter:
- StoreBlob hooks now accept (ctx, sha256, ext, body) instead of (ctx, sha256, body)
- LoadBlob hooks now accept (ctx, sha256, ext) instead of (ctx, sha256)
- DeleteBlob hooks now accept (ctx, sha256, ext) instead of (ctx, sha256)
- RejectGet hooks now accept (ctx, auth, sha256, ext) instead of (ctx, auth, sha256)
- RejectDelete hooks now accept (ctx, auth, sha256, ext) instead of (ctx, auth, sha256)
2025-09-08 16:06:24 -03:00
George
e81416f41e add ratelimit policy based on NIP-42 authentication 2025-08-23 16:07:21 -03:00
Jon Staab
e12d30f247 Fix panic on method name mismatch 2025-06-10 21:49:06 -03:00
fiatjaf
97bc365996 refactor @aaccioly's blossom redirect thing. 2025-06-10 16:22:48 -03:00
Jon Staab
44e4b86955 Don't list rejectapicall in nip86 supported methods 2025-06-10 15:43:22 -03:00
Jon Staab
932825080e Fix nil check 2025-06-10 15:43:22 -03:00
Jon Staab
aa81e17e22 Fix panic due to not handling an edge case 2025-06-10 15:43:22 -03:00
fiatjaf
034902c3e3 if there is an ephemeral hook the relay is never mute.
fixes https://github.com/fiatjaf/khatru/pull/53
2025-06-06 21:44:59 -03:00
Anthony Accioly
5705647c6b fix(blossom): remove extra . when serving files without extension
Ensure the correct filename is constructed when serving content without
an extension
2025-06-03 18:31:53 -03:00
Anthony Accioly
b2607e787f feat(docs): add redirection support details in Blossom usage guide 2025-06-03 18:31:53 -03:00
Anthony Accioly
011efe0dd2 feat(server): add functional options for BlossomServer configuration
Introduce `ServerOption` to configure `BlossomServer` with functional
options. Add `WithRedirectURL` to enable flexible redirect URL handling
with placeholders. Update `New` constructor to accept optional
configurations.
2025-06-03 18:31:53 -03:00
Anthony Accioly
16eee5cffb feat(blossom): add redirect support for GET requests 2025-06-03 18:31:53 -03:00
fiatjaf
c5076b297d handle files declared as .apk as .apk.
fixes https://github.com/fiatjaf/khatru/issues/50
2025-06-03 18:29:39 -03:00
Bitkarrot
1a5b498fb8 Update blossom.md (#42)
fix type 'Jut' to 'Just'
2025-05-09 15:15:59 -03:00
sudocarlos
b6da555807 blossom: return content-type in handleUpload() (#46) 2025-05-09 08:41:46 -03:00
sudocarlos
1ab44ab897 blossom: implement BUD-05 without optimizations (#45)
* blossom: implement BUD-05 without optimizations

* blossom: add redirect function, handle /media with 307 redirect to /upload

* blossome: remove duplicate /upload handle

* blossom: add content-length header to handleHasBlob()

* blossom: add content-type header to handleHasBlob()

* blossom: remove blossomRedirect() and use http.Redirect() for handleMedia() instead
2025-05-08 10:29:26 -03:00
fiatjaf
3da898cec7 blossom: implement BUD-04
closes https://github.com/fiatjaf/khatru/issues/43
2025-05-06 00:44:45 -03:00
fiatjaf
cfbe484784 docs: show fox. 2025-04-28 15:45:07 -03:00
fiatjaf
583f712fe4 admin: normalize urls for nip86 checking. 2025-04-17 08:02:25 -03:00
Jon Staab
28b1061166 Reject deleted events 2025-04-16 18:55:36 -03:00
Jon Staab
25f19ce46e Store and serve delete events 2025-04-16 18:55:28 -03:00
fiatjaf
33545587b6 make it so ephemeral events respond with ok:false if no one is listening. 2025-04-14 09:24:34 -03:00
Kay
214371f8bd refactor(adding): check kind range with proper function. 2025-04-13 09:05:23 -03:00
fiatjaf
fbb40f3b74 use .Find() instead of .GetFirst() everywhere. 2025-04-04 23:07:18 -03:00
fiatjaf
d97a2f1cf2 initialScan() 2025-04-04 17:55:16 -03:00
fiatjaf
c9a7d60543 remove event from expiration manager if it is deleted. 2025-04-03 23:11:47 -03:00
fiatjaf
2bb6d4d29a simplify WriteMessage, remove the defer since it's not needed. 2025-04-03 23:10:39 -03:00
fiatjaf
2292ce4a30 add missing return in repost protected clause. 2025-04-03 23:10:11 -03:00
fiatjaf
2ae219a34c add khatru.IsInternal() for dealing with internal calls specifically in QueryEvents() 2025-04-03 23:06:57 -03:00
fiatjaf
8c9394993b reject reposts that embed nip70 protected events.
in accordance with new stuff added to nip70 that makes some sense.
2025-03-28 18:08:49 -03:00
fiatjaf
850497956c include checkid length check from @pippellia-btc 2025-03-24 15:57:20 -03:00
andrewheadricke
28ce6cfb7a ensure suspected blossom request hash does not have slashes in it 2025-03-24 15:23:00 -03:00
fiatjaf
f47282c745 get rid of base64x temporarily since it doesn't work on arm64. 2025-03-19 15:02:56 -03:00
fiatjaf
f72dea346f rename menu item on docs to say "blossom". 2025-03-17 13:38:35 -03:00
fiatjaf
51632dcc9f update blossom example to use a different database.
closes https://github.com/fiatjaf/khatru/issues/36
2025-03-17 13:36:41 -03:00
andrewheadricke
6cc2477e89 fix blossom upload < 50bytes 2025-03-15 01:58:07 -03:00
fiatjaf
581c4ece28 updating go-nostr to fix sonic parser bug. 2025-03-14 20:10:52 -03:00
fiatjaf
596bca93c3 go-nostr MessageParser string transition. 2025-03-12 00:53:19 -03:00
fiatjaf
650d9209c3 policies: nip70 enforcer. 2025-03-11 17:42:27 -03:00
fiatjaf
0d736cff82 fix blossom authorization decoder. 2025-03-11 17:32:12 -03:00
fiatjaf
44ed6f519d use NewMessageParser() that allows sonic to be opted in with tags. 2025-03-11 17:32:04 -03:00
fiatjaf
db832d4255 use sonic json parser and other minor performance improvements. 2025-03-07 21:47:34 -03:00
fiatjaf
625bde38c5 update go-nostr so CountEnvelope only has one filter, which simplies COUNT handling a lot, specially with HLL. 2025-03-07 10:11:58 -03:00
fiatjaf
7c6031f4e5 resolve relative icon and banner urls in nip11 handler. 2025-02-23 18:21:23 -03:00
ZigBalthazar
6e224b9437 nip-86: stats, grant/revoke admin, listallowedevents and listdisallowedkinds 2025-02-19 12:50:21 -03:00
Kay
e9030a355c nip-86: add generic handler. 2025-02-10 09:27:52 -03:00
fiatjaf
a6ed7bced0 do not cancel subscription context on eose, only on subscription close. 2025-02-09 20:46:53 -03:00
fiatjaf
31128ebd18 filter policy: MustAuth() 2025-02-02 14:30:28 -03:00
k.
414867e62c feat(blossom): add bud-09 reporting handler. (#29) 2025-02-01 18:03:17 -03:00
Kay
65383d6d65 making it a little better, so compiler wont complain. 2025-01-28 15:26:38 -03:00
Anthony Accioly
c4d9ef1025 fix(blossom): respond with 401 on missing Auth 2025-01-26 17:23:29 -03:00
fiatjaf
5e5a53240a docs: fix broken links. 2025-01-13 17:40:51 -03:00
fiatjaf
3a9c88603b add blossom page. 2025-01-13 17:38:10 -03:00
fiatjaf
2403f82dac improve eventstore page. 2025-01-13 17:37:57 -03:00
fiatjaf
a289a77b6e restructure docs to have a more useful sidebar. 2025-01-13 17:33:58 -03:00
fiatjaf
1a03178d83 test expiration. 2025-01-13 17:02:15 -03:00
fiatjaf
c73037ac82 test replaceable events. 2025-01-13 16:52:17 -03:00
fiatjaf
433cceea86 include the ReplaceEvent setup on all examples. 2025-01-13 16:47:21 -03:00
fiatjaf
aa7422b2e4 tests for basic relay functionality. 2025-01-13 08:06:25 -03:00
fiatjaf
d27f582a0b nip-40 expiration manager. 2025-01-13 08:06:13 -03:00
fiatjaf
e1de0432fe fix @staab's mess. 2024-12-31 22:15:15 -03:00
Jon Staab
5b9b89543f Get service url when handling auth rather than mutating the relay, allow user to override service url via env var 2024-12-31 22:10:13 -03:00
fiatjaf
4dba9376a0 sanedefaults: allow a bigger burst of connections and events. 2024-12-10 23:40:03 -03:00
fiatjaf
fa523a6ab9 blossom: etag and cache-control headers. 2024-12-09 15:41:29 -03:00
fiatjaf
abbe61d128 use ReplaceEvent when available. 2024-12-08 00:50:29 -03:00
fiatjaf
e9999feec0 turns out we need to bump a version because of annoying hex nips. 2024-12-07 07:58:20 -03:00
fiatjaf
6275f52134 support hyperloglog handlers on COUNT. 2024-12-07 00:26:22 -03:00
fiatjaf
5b88b9b087 small tweaks to nip86 authorization flow and use rl.ServiceURL. 2024-11-25 14:11:27 -03:00
Your Name
1b4d81dde4 corrects malformed json in blossom list 2024-11-22 10:03:50 -03:00
Anthony Accioly
7bfdbb557c fix(blossom): use io.ReadSeeker in Blossom example
This should fix the failing pipeline test
2024-11-22 07:54:57 -03:00
girino
3f26a1f727 Change API of LoadBlob to return io.ReadSeeker instead of io.Reader and use ServeContent to serve blob content (#19)
* first test with http.ServeContent

* added debug to help me here

* removed debug messages

* changed LoadBlob to requisre io.ReadSeeker

---------

Co-authored-by: Your Name <you@example.com>
2024-11-21 11:58:12 -03:00
50 changed files with 1669 additions and 424 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*.env
rss-bridge
.idea/
knowledge.md

View File

@@ -127,6 +127,7 @@ Fear no more. Using the https://github.com/fiatjaf/eventstore module you get a b
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
```
### But I don't want to write a bunch of custom policies!

183
adding.go
View File

@@ -11,100 +11,147 @@ import (
// AddEvent sends an event through then normal add pipeline, as if it was received from a websocket.
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if evt == nil {
return false, errors.New("error: event is nil")
}
if nostr.IsEphemeralKind(evt.Kind) {
return false, rl.handleEphemeral(ctx, evt)
} else {
return rl.handleNormal(ctx, evt)
}
}
func (rl *Relay) handleNormal(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
for _, reject := range rl.RejectEvent {
if reject, msg := reject(ctx, evt); reject {
if msg == "" {
return false, errors.New("blocked: no reason")
return true, errors.New("blocked: no reason")
} else {
return false, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
return true, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
}
if 20000 <= evt.Kind && evt.Kind < 30000 {
// do not store ephemeral events
for _, oee := range rl.OnEphemeralEvent {
oee(ctx, evt)
// Check to see if the event has been deleted by ID
for _, query := range rl.QueryEvents {
ch, err := query(ctx, nostr.Filter{
Kinds: []int{5},
Tags: nostr.TagMap{"#e": []string{evt.ID}},
})
if err != nil {
continue
}
} else {
// will store
// but first check if we already have it
filter := nostr.Filter{IDs: []string{evt.ID}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for range ch {
// if we run this it means we already have this event, so we just return a success and exit
return true, nil
}
target := <-ch
if target == nil {
continue
}
// if it's replaceable we first delete old versions
if evt.Kind == 0 || evt.Kind == 3 || (10000 <= evt.Kind && evt.Kind < 20000) {
// replaceable event, delete before storing
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
}
}
}
} else if 30000 <= evt.Kind && evt.Kind < 40000 {
// parameterized replaceable event, delete before storing
d := evt.Tags.GetFirst([]string{"d", ""})
if d == nil {
return false, fmt.Errorf("invalid: missing 'd' tag on parameterized replaceable event")
}
return true, errors.New("blocked: this event has been deleted")
}
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{(*d)[1]}}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
}
}
}
}
// store
// will store
// regular kinds are just saved directly
if nostr.IsRegularKind(evt.Kind) {
for _, store := range rl.StoreEvent {
if saveErr := store(ctx, evt); saveErr != nil {
switch saveErr {
if err := store(ctx, evt); err != nil {
switch err {
case eventstore.ErrDupEvent:
return true, nil
default:
return false, fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
return false, fmt.Errorf("%s", nostr.NormalizeOKMessage(err.Error(), "error"))
}
}
}
} else {
// Check to see if the event has been deleted by address
for _, query := range rl.QueryEvents {
dTagValue := ""
for _, tag := range evt.Tags {
if len(tag) > 0 && tag[0] == "d" {
dTagValue = tag[1]
break
}
}
for _, ons := range rl.OnEventSaved {
ons(ctx, evt)
address := fmt.Sprintf("%d:%s:%s", evt.Kind, evt.PubKey, dTagValue)
ch, err := query(ctx, nostr.Filter{
Kinds: []int{5},
Since: &evt.CreatedAt,
Tags: nostr.TagMap{"#a": []string{address}},
})
if err != nil {
continue
}
target := <-ch
if target == nil {
continue
}
return true, errors.New("blocked: this event has been deleted")
}
// otherwise it's a replaceable -- so we'll use the replacer functions if we have any
if len(rl.ReplaceEvent) > 0 {
for _, repl := range rl.ReplaceEvent {
if err := repl(ctx, evt); err != nil {
switch err {
case eventstore.ErrDupEvent:
return true, nil
default:
return false, fmt.Errorf("%s", nostr.NormalizeOKMessage(err.Error(), "error"))
}
}
}
} else {
// otherwise do it the manual way
filter := nostr.Filter{Limit: 1, Kinds: []int{evt.Kind}, Authors: []string{evt.PubKey}}
if nostr.IsAddressableKind(evt.Kind) {
// when addressable, add the "d" tag to the filter
filter.Tags = nostr.TagMap{"d": []string{evt.Tags.GetD()}}
}
// now we fetch old events and delete them
shouldStore := true
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
} else {
// we found a more recent event, so we won't delete it and also will not store this new one
shouldStore = false
}
}
}
// store
if shouldStore {
for _, store := range rl.StoreEvent {
if saveErr := store(ctx, evt); saveErr != nil {
switch saveErr {
case eventstore.ErrDupEvent:
return true, nil
default:
return false, fmt.Errorf("%s", nostr.NormalizeOKMessage(saveErr.Error(), "error"))
}
}
}
}
}
}
for _, ons := range rl.OnEventSaved {
ons(ctx, evt)
}
// track event expiration if applicable
rl.expirationManager.trackEvent(evt)
return false, nil
}

View File

@@ -1,15 +1,13 @@
package blossom
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
)
@@ -19,25 +17,26 @@ func readAuthorization(r *http.Request) (*nostr.Event, error) {
return nil, nil
}
var reader io.Reader
reader = bytes.NewReader([]byte(token)[6:])
reader = base64.NewDecoder(base64.StdEncoding, reader)
eventj, err := base64.StdEncoding.DecodeString(token[6:])
if err != nil {
return nil, fmt.Errorf("invalid base64 token")
}
var evt nostr.Event
err := json.NewDecoder(reader).Decode(&evt)
if err != nil || evt.Kind != 24242 || len(evt.ID) != 64 || !evt.CheckID() {
if err := easyjson.Unmarshal(eventj, &evt); err != nil {
return nil, fmt.Errorf("broken event")
}
if evt.Kind != 24242 || !evt.CheckID() {
return nil, fmt.Errorf("invalid event")
}
if ok, _ := evt.CheckSignature(); !ok {
return nil, fmt.Errorf("invalid signature")
}
expirationTag := evt.Tags.GetFirst([]string{"expiration", ""})
expirationTag := evt.Tags.Find("expiration")
if expirationTag == nil {
return nil, fmt.Errorf("missing \"expiration\" tag")
}
expiration, _ := strconv.ParseInt((*expirationTag)[1], 10, 64)
expiration, _ := strconv.ParseInt(expirationTag[1], 10, 64)
if nostr.Timestamp(expiration) < nostr.Now() {
return nil, fmt.Errorf("event expired")
}

View File

@@ -7,8 +7,10 @@ import (
"io"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/liamg/magic"
"github.com/nbd-wtf/go-nostr"
@@ -21,10 +23,10 @@ func (bs BlossomServer) handleUploadCheck(w http.ResponseWriter, r *http.Request
return
}
if auth == nil {
blossomError(w, "missing \"Authorization\" header", 400)
blossomError(w, "missing \"Authorization\" header", 401)
return
}
if auth.Tags.GetFirst([]string{"t", "upload"}) == nil {
if auth.Tags.FindWithValue("t", "upload") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
@@ -51,14 +53,14 @@ func (bs BlossomServer) handleUploadCheck(w http.ResponseWriter, r *http.Request
func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
auth, err := readAuthorization(r)
if err != nil {
blossomError(w, "invalid \"Authorization\": "+err.Error(), 400)
blossomError(w, "invalid \"Authorization\": "+err.Error(), 404)
return
}
if auth == nil {
blossomError(w, "missing \"Authorization\" header", 400)
blossomError(w, "missing \"Authorization\" header", 401)
return
}
if auth.Tags.GetFirst([]string{"t", "upload"}) == nil {
if auth.Tags.FindWithValue("t", "upload") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
@@ -72,7 +74,7 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
// read first bytes of upload so we can find out the filetype
b := make([]byte, min(50, size), size)
if _, err = r.Body.Read(b); err != nil {
if n, err := r.Body.Read(b); err != nil && n != size {
blossomError(w, "failed to read initial bytes of upload body: "+err.Error(), 400)
return
}
@@ -85,6 +87,11 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
ext = getExtension(mimetype)
}
// special case of android apk -- if we see a .zip but they say it's .apk we trust them
if ext == ".zip" && getExtension(r.Header.Get("Content-Type")) == ".apk" {
ext = ".apk"
}
// run the reject hooks
for _, ru := range bs.RejectUpload {
reject, reason, code := ru(r.Context(), auth, size, ext)
@@ -118,13 +125,17 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
hash := sha256.Sum256(b)
hhash := hex.EncodeToString(hash[:])
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
// keep track of the blob descriptor
bd := BlobDescriptor{
URL: bs.ServiceURL + "/" + hhash + ext,
SHA256: hhash,
Size: len(b),
Type: mime.TypeByExtension(ext),
Type: mimeType,
Uploaded: nostr.Now(),
}
if err := bs.Store.Keep(r.Context(), bd, auth.PubKey); err != nil {
@@ -134,13 +145,14 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
// save actual blob
for _, sb := range bs.StoreBlob {
if err := sb(r.Context(), hhash, b); err != nil {
if err := sb(r.Context(), hhash, ext, b); err != nil {
blossomError(w, "failed to save: "+err.Error(), 500)
return
}
}
// return response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bd)
}
@@ -162,42 +174,78 @@ func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
// if there is one, we check if it has the extra requirements
if auth != nil {
if auth.Tags.GetFirst([]string{"t", "get"}) == nil {
if auth.Tags.FindWithValue("t", "get") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
if auth.Tags.GetFirst([]string{"x", hhash}) == nil &&
auth.Tags.GetFirst([]string{"server", bs.ServiceURL}) == nil {
if auth.Tags.FindWithValue("x", hhash) == nil &&
auth.Tags.FindWithValue("server", bs.ServiceURL) == nil {
blossomError(w, "invalid \"Authorization\" event \"x\" or \"server\" tag", 403)
return
}
}
var ext string
bd, err := bs.Store.Get(r.Context(), hhash)
if err != nil {
// can't find the BlobDescriptor, try to get the extension from the URL
if len(spl) == 2 {
ext = spl[1]
}
} else if bd != nil {
ext = getExtension(bd.Type)
}
for _, rg := range bs.RejectGet {
reject, reason, code := rg(r.Context(), auth, hhash)
reject, reason, code := rg(r.Context(), auth, hhash, ext)
if reject {
blossomError(w, reason, code)
return
}
}
var ext string
if len(spl) == 2 {
ext = "." + spl[1]
if len(bs.RedirectGet) > 0 {
for _, redirect := range bs.RedirectGet {
redirectURL, code, err := redirect(r.Context(), hhash, ext)
if err == nil && redirectURL != "" {
// check that the redirectURL contains the hash of the file
if ok, _ := regexp.MatchString(`\b`+hhash+`\b`, redirectURL); !ok {
continue
}
// not sure if browsers will cache redirects
// but it doesn't hurt anyway
w.Header().Set("ETag", hhash)
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.Redirect(w, r, redirectURL, code)
return
}
}
}
for _, lb := range bs.LoadBlob {
reader, _ := lb(r.Context(), hhash)
reader, _ := lb(r.Context(), hhash, ext)
if reader != nil {
w.Header().Add("Content-Type", mime.TypeByExtension(ext))
io.Copy(w, reader)
// use unix epoch as the time if we can't find the descriptor
// as described in the http.ServeContent documentation
t := time.Unix(0, 0)
descriptor, err := bs.Store.Get(r.Context(), hhash)
if err == nil && descriptor != nil {
t = descriptor.Uploaded.Time()
}
w.Header().Set("ETag", hhash)
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
name := hhash
if ext != "" {
name += "." + ext
}
http.ServeContent(w, r, name, t, reader)
return
}
}
blossomError(w, "file not found", 404)
return
}
func (bs BlossomServer) handleHasBlob(w http.ResponseWriter, r *http.Request) {
@@ -219,8 +267,9 @@ func (bs BlossomServer) handleHasBlob(w http.ResponseWriter, r *http.Request) {
blossomError(w, "file not found", 404)
return
}
return
w.Header().Set("Content-Length", strconv.Itoa(bd.Size))
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Type", bd.Type)
}
func (bs BlossomServer) handleList(w http.ResponseWriter, r *http.Request) {
@@ -233,7 +282,7 @@ func (bs BlossomServer) handleList(w http.ResponseWriter, r *http.Request) {
// if there is one, we check if it has the extra requirements
if auth != nil {
if auth.Tags.GetFirst([]string{"t", "list"}) == nil {
if auth.Tags.FindWithValue("t", "list") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
@@ -257,7 +306,13 @@ func (bs BlossomServer) handleList(w http.ResponseWriter, r *http.Request) {
w.Write([]byte{'['})
enc := json.NewEncoder(w)
first := true
for bd := range ch {
if !first {
w.Write([]byte{','})
} else {
first = false
}
enc.Encode(bd)
}
w.Write([]byte{']'})
@@ -271,7 +326,7 @@ func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
}
if auth != nil {
if auth.Tags.GetFirst([]string{"t", "delete"}) == nil {
if auth.Tags.FindWithValue("t", "delete") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
@@ -284,15 +339,26 @@ func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
hhash = hhash[1:]
if auth.Tags.GetFirst([]string{"x", hhash}) == nil &&
auth.Tags.GetFirst([]string{"server", bs.ServiceURL}) == nil {
if auth.Tags.FindWithValue("x", hhash) == nil &&
auth.Tags.FindWithValue("server", bs.ServiceURL) == nil {
blossomError(w, "invalid \"Authorization\" event \"x\" or \"server\" tag", 403)
return
}
var ext string
bd, err := bs.Store.Get(r.Context(), hhash)
if err != nil {
// can't find the BlobDescriptor, try to get the extension from the URL
if len(spl) == 2 {
ext = spl[1]
}
} else if bd != nil {
ext = getExtension(bd.Type)
}
// should we accept this delete?
for _, rd := range bs.RejectDelete {
reject, reason, code := rd(r.Context(), auth, hhash)
reject, reason, code := rd(r.Context(), auth, hhash, ext)
if reject {
blossomError(w, reason, code)
return
@@ -308,7 +374,7 @@ func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
// we will actually only delete the file if no one else owns it
if bd, err := bs.Store.Get(r.Context(), hhash); err == nil && bd == nil {
for _, del := range bs.DeleteBlob {
if err := del(r.Context(), hhash); err != nil {
if err := del(r.Context(), hhash, ext); err != nil {
blossomError(w, "failed to delete blob: "+err.Error(), 500)
return
}
@@ -316,7 +382,133 @@ func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
}
}
func (bs BlossomServer) handleReport(w http.ResponseWriter, r *http.Request) {
var body []byte
_, err := r.Body.Read(body)
if err != nil {
blossomError(w, "can't read request body", 400)
return
}
var evt nostr.Event
if err := json.Unmarshal(body, &evt); err != nil {
blossomError(w, "can't parse event", 400)
return
}
if isValid, _ := evt.CheckSignature(); !isValid {
blossomError(w, "invalid report event is provided", 400)
return
}
if evt.Kind != nostr.KindReporting {
blossomError(w, "invalid report event is provided", 400)
return
}
for _, rr := range bs.ReceiveReport {
if err := rr(r.Context(), &evt); err != nil {
blossomError(w, "failed to receive report: "+err.Error(), 500)
return
}
}
}
func (bs BlossomServer) handleMirror(w http.ResponseWriter, r *http.Request) {
auth, err := readAuthorization(r)
if err != nil {
blossomError(w, "invalid \"Authorization\": "+err.Error(), 404)
return
}
if auth == nil {
blossomError(w, "missing \"Authorization\" header", 401)
return
}
if auth.Tags.FindWithValue("t", "upload") == nil {
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
blossomError(w, "invalid request body: "+err.Error(), 400)
return
}
// download the blob
resp, err := http.Get(body.URL)
if err != nil {
blossomError(w, "failed to download blob: "+err.Error(), 400)
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
blossomError(w, "failed to read blob: "+err.Error(), 400)
return
}
// calculate sha256 hash
hash := sha256.Sum256(b)
hhash := hex.EncodeToString(hash[:])
// verify hash matches x tag in auth event
if auth.Tags.FindWithValue("x", hhash) == nil {
blossomError(w, "blob hash does not match any \"x\" tag in authorization event", 403)
return
}
// get content type and extension
var ext string
contentType := resp.Header.Get("Content-Type")
if contentType != "" { // First try to get the extension from the Content-Type header
ext = getExtension(contentType)
} else if ft, _ := magic.Lookup(b); ft != nil { // Else try to infer extension from the file content
ext = "." + ft.Extension
} else if idx := strings.LastIndex(body.URL, "."); idx >= 0 { // Else, try to get the extension from the URL
ext = body.URL[idx:]
}
// run reject hooks
for _, ru := range bs.RejectUpload {
reject, reason, code := ru(r.Context(), auth, len(b), ext)
if reject {
blossomError(w, reason, code)
return
}
}
// create blob descriptor
bd := BlobDescriptor{
URL: bs.ServiceURL + "/" + hhash + ext,
SHA256: hhash,
Size: len(b),
Type: contentType,
Uploaded: nostr.Now(),
}
// store blob metadata
if err := bs.Store.Keep(r.Context(), bd, auth.PubKey); err != nil {
blossomError(w, "failed to save metadata: "+err.Error(), 400)
return
}
// store actual blob
for _, sb := range bs.StoreBlob {
if err := sb(r.Context(), hhash, ext, b); err != nil {
blossomError(w, "failed to save blob: "+err.Error(), 500)
return
}
}
json.NewEncoder(w).Encode(bd)
}
func (bs BlossomServer) handleMedia(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/upload", 307)
return
}
func (bs BlossomServer) handleNegentropy(w http.ResponseWriter, r *http.Request) {

View File

@@ -14,16 +14,23 @@ type BlossomServer struct {
ServiceURL string
Store BlobIndex
StoreBlob []func(ctx context.Context, sha256 string, body []byte) error
LoadBlob []func(ctx context.Context, sha256 string) (io.Reader, error)
DeleteBlob []func(ctx context.Context, sha256 string) error
StoreBlob []func(ctx context.Context, sha256 string, ext string, body []byte) error
LoadBlob []func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, error)
DeleteBlob []func(ctx context.Context, sha256 string, ext string) error
ReceiveReport []func(ctx context.Context, reportEvt *nostr.Event) error
RedirectGet []func(ctx context.Context, sha256 string, ext string) (url string, code int, err error)
RejectUpload []func(ctx context.Context, auth *nostr.Event, size int, ext string) (bool, string, int)
RejectGet []func(ctx context.Context, auth *nostr.Event, sha256 string) (bool, string, int)
RejectGet []func(ctx context.Context, auth *nostr.Event, sha256 string, ext string) (bool, string, int)
RejectList []func(ctx context.Context, auth *nostr.Event, pubkey string) (bool, string, int)
RejectDelete []func(ctx context.Context, auth *nostr.Event, sha256 string) (bool, string, int)
RejectDelete []func(ctx context.Context, auth *nostr.Event, sha256 string, ext string) (bool, string, int)
}
// ServerOption represents a functional option for configuring a BlossomServer
type ServerOption func(*BlossomServer)
// New creates a new BlossomServer with the given relay and service URL
// Optional configuration can be provided via functional options
func New(rl *khatru.Relay, serviceURL string) *BlossomServer {
bs := &BlossomServer{
ServiceURL: serviceURL,
@@ -42,13 +49,21 @@ func New(rl *khatru.Relay, serviceURL string) *BlossomServer {
return
}
}
if r.URL.Path == "/media" {
bs.handleMedia(w, r)
return
}
if r.URL.Path == "/mirror" && r.Method == "PUT" {
bs.handleMirror(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/list/") && r.Method == "GET" {
bs.handleList(w, r)
return
}
if len(strings.SplitN(r.URL.Path, ".", 2)[0]) == 65 {
if (len(r.URL.Path) == 65 || strings.Index(r.URL.Path, ".") == 65) && strings.Index(r.URL.Path[1:], "/") == -1 {
if r.Method == "HEAD" {
bs.handleHasBlob(w, r)
return
@@ -61,6 +76,13 @@ func New(rl *khatru.Relay, serviceURL string) *BlossomServer {
}
}
if r.URL.Path == "/report" {
if r.Method == "PUT" {
bs.handleReport(w, r)
return
}
}
base.ServeHTTP(w, r)
})

View File

@@ -26,6 +26,8 @@ func getExtension(mimetype string) string {
return ".webp"
case "video/mp4":
return ".mp4"
case "application/vnd.android.package-archive":
return ".apk"
}
exts, _ := mime.ExtensionsByType(mimetype)

View File

@@ -6,6 +6,6 @@ import (
// BroadcastEvent emits an event to all listeners whose filters' match, skipping all filters and actions
// it also doesn't attempt to store the event or trigger any reactions or callbacks
func (rl *Relay) BroadcastEvent(evt *nostr.Event) {
rl.notifyListeners(evt)
func (rl *Relay) BroadcastEvent(evt *nostr.Event) int {
return rl.notifyListeners(evt)
}

View File

@@ -39,6 +39,7 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
continue
}
ctx := context.WithValue(ctx, internalCallKey, struct{}{})
for _, query := range rl.QueryEvents {
ch, err := query(ctx, f)
if err != nil {
@@ -66,6 +67,9 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
return err
}
}
// if it was tracked to be expired that is not needed anymore
rl.expirationManager.removeEvent(target.ID)
} else {
// fail and stop here
return fmt.Errorf("blocked: %s", msg)

View File

@@ -7,11 +7,32 @@ export default {
nav: [
{text: 'Home', link: '/'},
{text: 'Why', link: '/why'},
{text: 'Use Cases', link: '/use-cases'},
{text: 'Get Started', link: '/getting-started'},
{text: 'Cookbook', link: '/cookbook'},
{text: 'Docs', link: '/getting-started'},
{text: 'Source', link: 'https://github.com/fiatjaf/khatru'}
],
sidebar: [
{
text: 'Core Concepts',
items: [
{ text: 'Event Storage', link: '/core/eventstore' },
{ text: 'Authentication', link: '/core/auth' },
{ text: 'HTTP Integration', link: '/core/embed' },
{ text: 'Request Routing', link: '/core/routing' },
{ text: 'Management API', link: '/core/management' },
{ text: 'Media Storage (Blossom)', link: '/core/blossom' },
]
},
{
text: 'Cookbook',
items: [
{ text: 'Search', link: '/cookbook/search' },
{ text: 'Dynamic Relays', link: '/cookbook/dynamic' },
{ text: 'Generating Events Live', link: '/cookbook/custom-live-events' },
{ text: 'Custom Stores', link: '/cookbook/custom-stores' },
{ text: 'Using something like Google Drive', link: '/cookbook/google-drive' },
]
}
],
editLink: {
pattern: 'https://github.com/fiatjaf/khatru/edit/master/docs/:path'
}

View File

@@ -1,11 +0,0 @@
# Cookbook
- [Dealing with `AUTH` messages and authenticated users](auth)
- [Configuring the Relay Management API](management)
- [Using the eventstore library](eventstore)
- [Custom store: creating Nostr events on the fly from a non-Nostr source](custom-stores)
- [Custom store: reading from Google Drive](google-drive)
- [Live event generation](custom-live-events)
- [Embedding `khatru` inside other Go HTTP servers](embed)
- [Generating relays dynamically and serving them from the same path](dynamic)
- [Routing between multiple relays](routing)

View File

@@ -6,7 +6,7 @@ outline: deep
The [`nostr.Filter` type](https://pkg.go.dev/github.com/nbd-wtf/go-nostr#Filter) has a `Search` field, so you basically just has to handle that if it's present.
It can be tricky to implement fulltext search properly though, so some [eventstores](eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded).
It can be tricky to implement fulltext search properly though, so some [eventstores](../core/eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded).
If you have any of these you can just use them just like any other eventstore:

131
docs/core/blossom.md Normal file
View File

@@ -0,0 +1,131 @@
---
outline: deep
---
# Blossom: Media Storage
Khatru comes with a built-in Blossom HTTP handler that allows you to store and serve media blobs using storage backend you want (filesystem, S3 etc).
## Basic Setup
Here's a minimal example of what you should do to enable it:
```go
func main() {
relay := khatru.NewRelay()
// create blossom server with the relay and service URL
bl := blossom.New(relay, "http://localhost:3334")
// create a database for keeping track of blob metadata
// (do not use the same database used for the relay events)
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: blobdb, ServiceURL: bl.ServiceURL}
// implement the required storage functions
bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, ext string, body []byte) error {
// store the blob data somewhere
return nil
})
bl.LoadBlob = append(bl.LoadBlob, func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, error) {
// load and return the blob data
return nil, nil
})
bl.DeleteBlob = append(bl.DeleteBlob, func(ctx context.Context, sha256 string, ext string) error {
// delete the blob data
return nil
})
http.ListenAndServe(":3334", relay)
}
```
## Storage Backend Integration
You can integrate any storage backend by implementing the three core functions:
- `StoreBlob`: Save the blob data
- `LoadBlob`: Retrieve the blob data
- `DeleteBlob`: Remove the blob data
## URL Redirection
Blossom supports redirection to external storage locations when retrieving blobs. This is useful when you want to serve files from a CDN or cloud storage service while keeping Blossom compatibility.
You can implement a custom redirect function. This function should return a string with the redirect URL and an HTTP status code.
Here's an example that redirects to a templated URL:
```go
import "github.com/fiatjaf/khatru/policies"
// ...
bl.RedirectGet = append(bl.RedirectGet, policies.RedirectGet("https://blossom.example.com", http.StatusMovedPermanently))
```
The `RedirectGet` hook will append the blob's SHA256 hash and file extension to the redirect URL.
For example, if the blob's SHA256 hash is `b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553` and the file extension is `pdf`, the redirect URL will be `https://blossom.exampleserver.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf`.
You can also customize the redirect URL by passing `{sha256}` and `{extension}` placeholders in the URL. For example:
```go
bl.RedirectGet = append(bl.RedirectGet, policies.RedirectGet("https://mybucket.myblobstorage.com/{sha256}.{extension}?ref=xxxx", http.StatusFound))
```
If you need more control over the redirect URL, you can implement a custom redirect function from scratch. This function should return a string with the redirect URL and an HTTP status code.
```go
bl.RedirectGet = append(bl.RedirectGet, func(ctx context.Context, sha256 string, ext string) (string, int, error) {
// generate a custom redirect URL
cid := IPFSCID(sha256)
redirectURL := fmt.Sprintf("https://ipfs.io/ipfs/%s/%s.%s", cid, sha256, ext)
return redirectURL, http.StatusTemporaryRedirect, nil
})
```
This URL must include the sha256 hash somewhere. If you return an empty string `""` as the URL, your redirect call will be ignored and the next one in the chain (if any) will be called.
## Upload Restrictions
You can implement upload restrictions using the `RejectUpload` hook. Here's an example that limits file size and restricts uploads to whitelisted users:
```go
const maxFileSize = 10 * 1024 * 1024 // 10MB
var allowedUsers = map[string]bool{
"pubkey1": true,
"pubkey2": true,
}
bl.RejectUpload = append(bl.RejectUpload, func(ctx context.Context, auth *nostr.Event, size int, ext string) (bool, string, int) {
// check file size
if size > maxFileSize {
return true, "file too large", 413
}
// check if user is allowed
if auth == nil || !allowedUsers[auth.PubKey] {
return true, "unauthorized", 403
}
return false, "", 0
})
```
There are other `Reject*` hooks you can also implement, but this is the most important one.
## Tracking blob metadata
Blossom needs a database to keep track of blob metadata in order to know which user owns each blob, for example (and mind you that more than one user might own the same blob so when of them deletes the blob we don't actually delete it because the other user still has a claim to it). The simplest way to do it currently is by relying on a wrapper on top of fake Nostr events over eventstore, which is `EventStoreBlobIndexWrapper`, but other solutions can be used.
```go
db := &badger.BadgerBackend{Path: "/tmp/khatru-badger-blossom-blobstore"}
db.Init()
bl.Store = blossom.EventStoreBlobIndexWrapper{
Store: db,
ServiceURL: bl.ServiceURL,
}
```
This will store blob metadata as special `kind:24242` events, but you shouldn't have to worry about it as the wrapper handles all the complexity of tracking ownership and managing blob lifecycle. Just avoid reusing the same datastore that is used for the actual relay events unless you know what you're doing.

View File

@@ -69,4 +69,4 @@ Every [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) insta
That's also possible, as long as you have a way of differentiating each HTTP request that comes at the middleware level and associating it with a `khatru.Relay` instance in the background.
See [dynamic](dynamic) for an example that does that using the subdomain. [`countries`](https://git.fiatjaf.com/countries) does it using the requester country implied from its IP address.
See [dynamic](../cookbook/dynamic) for an example that does that using the subdomain. [`countries`](https://git.fiatjaf.com/countries) does it using the requester country implied from its IP address.

View File

@@ -2,14 +2,15 @@
outline: deep
---
# Event Storage
Khatru doesn't make any assumptions about how you'll want to store events. Any function can be plugged in to the `StoreEvent`, `DeleteEvent`, `ReplaceEvent` and `QueryEvents` hooks.
However the [`eventstore`](https://github.com/fiatjaf/eventstore) library has adapters that you can easily plug into `khatru`'s hooks.
# Using the `eventstore` library
The [`eventstore`](https://github.com/fiatjaf/eventstore) library has adapters that you can easily plug into `khatru`'s:
* `StoreEvent`
* `DeleteEvent`
* `QueryEvents`
* `CountEvents`
The library includes many different adapters -- often called "backends" --, written by different people and with different levels of quality, reliability and speed.
For all of them you start by instantiating a struct containing some basic options and a pointer (a file path for local databases, a connection string for remote databases) to the data. Then you call `.Init()` and if all is well you're ready to start storing, querying and deleting events, so you can pass the respective functions to their `khatru` counterparts. These eventstores also expose a `.Close()` function that must be called if you're going to stop using that store and keep your application open.
@@ -38,6 +39,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
@@ -95,7 +97,3 @@ For example, maybe you want kind 1 events in `db1` and kind 30023 events in `db3
}
})
```
## Search
See [search](search).

View File

@@ -2,7 +2,7 @@
outline: deep
---
# Setting up the Relay Management API
# Management API
[NIP-86](https://nips.nostr.com/86) specifies a set of RPC methods for managing the boring aspects of relays, such as whitelisting or banning users, banning individual events, banning IPs and so on.

View File

@@ -2,7 +2,7 @@
outline: deep
---
# Routing
# Request Routing
If you have one (or more) set of policies that have to be executed in sequence (for example, first you check for the presence of a tag, then later in the next policies you use that tag without checking) and they only apply to some class of events, but you still want your relay to deal with other classes of events that can lead to cumbersome sets of rules, always having to check if an event meets the requirements and so on. There is where routing can help you.
@@ -47,7 +47,7 @@ router.Route().
return true
case event.Kind <= 12 && event.Kind >= 9:
return true
case event.Tags.GetFirst([]string{"h", ""}) != nil:
case event.Tags.Find("h") != nil:
return true
default:
return false

View File

@@ -42,6 +42,7 @@ if err := db.Init(); err != nil {
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
```
These are lists of functions that will be called in order every time an `EVENT` is received, or a `REQ` query is received. You can add more than one handler there, you can have a function that reads from some other server, but just in some cases, you can do anything.
@@ -66,7 +67,7 @@ import "github.com/fiatjaf/khatru" // implied
relay.RejectEvent = append(relay.RejectEvent, policies.PreventLargeTags(120), policies.PreventTimestampsInThePast(time.Hour * 2), policies.PreventTimestampsInTheFuture(time.Minute * 30))
```
There are many other ways to customize the relay behavior. Take a look at the [`Relay` struct docs](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) for more, or see the [cookbook](/cookbook/).
There are many other ways to customize the relay behavior. Take a look at the [`Relay` struct docs](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) for more, or read the pages on the sidebar.
The last step is actually running the server. Our relay is actually an `http.Handler`, so it can just be ran directly with `http.ListenAndServe()` from the standard library:

View File

@@ -4,14 +4,13 @@ layout: home
hero:
name: khatru
text: a framework for making Nostr relays
image:
src: /logo.png
tagline: write your custom relay with code over configuration
actions:
- theme: brand
text: Get Started
link: /getting-started
- theme: alt
text: Cookbook
link: /cookbook/
features:
- title: It's a library
@@ -20,19 +19,19 @@ features:
details: This is not an executable that you have to tweak with config files, it's a library that you import and use, so you just write code and it does exactly what you want.
- title: It's very very customizable
icon: 🎶
link: /cookbook/embed
link: /core/embed
details: Run arbitrary functions to reject events, reject filters, overwrite results of queries, perform actual queries, mix the relay stuff with other HTTP handlers or even run it inside an existing website.
- title: It plugs into event stores easily
icon: 📦
link: /cookbook/eventstore
link: /core/eventstore
details: khatru's companion, the `eventstore` library, provides all methods for storing and querying events efficiently from SQLite, LMDB, Postgres, Badger and others.
- title: It supports NIP-42 AUTH
icon: 🪪
link: /cookbook/auth
link: /core/auth
details: You can check if a client is authenticated or request AUTH anytime, or reject an event or a filter with an "auth-required:" and it will be handled automatically.
- title: It supports NIP-86 Management API
icon: 🛠️
link: /cookbook/management
link: /core/management
details: You just define your custom handlers for each RPC call and they will be exposed appropriately to management clients.
- title: It's written in Go
icon: 🛵
@@ -52,6 +51,7 @@ func main() {
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
http.ListenAndServe(":3334", relay)
}
```

View File

@@ -1,27 +0,0 @@
# Use cases
`khatru` is being used today in the real world by
* [pyramid](https://github.com/github-tijlxyz/khatru-pyramid), a relay with a invite-based whitelisting system similar to [lobste.rs](https://lobste.rs)
* [triflector](https://github.com/coracle-social/triflector), a relay which enforces authentication based on custom policy
* [countries](https://git.fiatjaf.com/countries), a relay that stores and serves content differently according to the country of the reader or writer
* [jingle](https://github.com/fiatjaf/jingle), a simple relay that exposes part of `khatru`'s configuration options to JavaScript code supplied by the user that is interpreted at runtime
* [njump](https://git.njump.me/njump), a Nostr gateway to the web that also serves its cached content in a relay interface
* [song](https://git.fiatjaf.com/song), a personal git server that comes with an embedded relay dedicated to dealing with [NIP-34](https://nips.nostr.com/34) git-related Nostr events
* [relay29](https://github.com/fiatjaf/relay29), a relay that powers most of the [NIP-29](https://nips.nostr.com/29) Nostr groups ecosystem
* [fiatjaf.com](https://fiatjaf.com), a personal website that serves the same content as HTML but also as Nostr events.
* [gm-relay](https://github.com/ptrio42/gm-relay), a relay that only accepts GM notes once a day.
## Other possible use cases
Other possible use cases, still not developed, include:
* Bridges: `khatru` was initially developed to serve as an RSS-to-Nostr bridge server that would fetch RSS feeds on demand in order to serve them to Nostr clients. Other similar use cases could fit.
* Paid relays: Nostr has multiple relays that charge for write-access currently, but there are many other unexplored ways to make this scheme work: charge per each note, charge per month, charge per month per note, have different payment methods, and so on.
* Other whitelisting schemes: _pyramid_ implements a cool inviting scheme for granting access to the relay, same for _triflector_, but there are infinite other possibilities of other ways to grant access to people to an exclusive or community relay.
* Just-in-time content generation: instead of storing a bunch of signed JSON and serving that to clients, there could be relays that store data in a more compact format and turn it into Nostr events at the time they receive a request from a Nostr client -- or relays that do some kind of live data generation based on who is connected, not storing anything.
* Community relays: some internet communities may want relays that restrict writing or browsing of content only to its members, essentially making it a closed group -- or it could be closed for outsiders to write, but public for them to read and vice-versa.
* Automated moderation schemes: relays that are owned by a group (either a static or a dynamic group) can rely on signals from their members, like mutes or reports, to decide what content to allow in its domains and what to disallow, making crowdfunded moderation easy.
* Curation: in the same way as community relays can deal with unwanted content, they can also perform curation based on signals from their members (for example, if a member of the relay likes some note from someone that is outside the relay that note can be fetched and stored), creating a dynamic relay that can be browsed by anyone that share the same interests as that community.
* Local relays: a relay that can be only browsed by people using the WiFi connection of some event or some building, serving as a way to share temporary or restricted content that only interests people sharing that circumstance.
* Cool experiments: relays that only allow one note per user per day, relays that require proof-of-work on event ids], relays that require engagement otherwise you get kicked, relays that return events in different ordering, relays that impose arbitrary funny rules on notes in order for them to be accepted (i.e. they must contain the word "poo"), I don't know!

View File

@@ -8,3 +8,31 @@ If you want to craft a relay that isn't completely dumb, but it's supposed to
* and other stuff.
`khatru` provides a simple framework for creating your custom relay without having to reimplement it all from scratch or hack into other relay codebases.
# Use cases
`khatru` is being used today in the real world by
* [pyramid](https://github.com/github-tijlxyz/khatru-pyramid), a relay with a invite-based whitelisting system similar to [lobste.rs](https://lobste.rs)
* [triflector](https://github.com/coracle-social/triflector), a relay which enforces authentication based on custom policy
* [countries](https://git.fiatjaf.com/countries), a relay that stores and serves content differently according to the country of the reader or writer
* [jingle](https://github.com/fiatjaf/jingle), a simple relay that exposes part of `khatru`'s configuration options to JavaScript code supplied by the user that is interpreted at runtime
* [njump](https://git.njump.me/njump), a Nostr gateway to the web that also serves its cached content in a relay interface
* [song](https://git.fiatjaf.com/song), a personal git server that comes with an embedded relay dedicated to dealing with [NIP-34](https://nips.nostr.com/34) git-related Nostr events
* [relay29](https://github.com/fiatjaf/relay29), a relay that powers most of the [NIP-29](https://nips.nostr.com/29) Nostr groups ecosystem
* [fiatjaf.com](https://fiatjaf.com), a personal website that serves the same content as HTML but also as Nostr events.
* [gm-relay](https://github.com/ptrio42/gm-relay), a relay that only accepts GM notes once a day.
## Other possible use cases
Other possible use cases, still not developed, include:
* Bridges: `khatru` was initially developed to serve as an RSS-to-Nostr bridge server that would fetch RSS feeds on demand in order to serve them to Nostr clients. Other similar use cases could fit.
* Paid relays: Nostr has multiple relays that charge for write-access currently, but there are many other unexplored ways to make this scheme work: charge per each note, charge per month, charge per month per note, have different payment methods, and so on.
* Other whitelisting schemes: _pyramid_ implements a cool inviting scheme for granting access to the relay, same for _triflector_, but there are infinite other possibilities of other ways to grant access to people to an exclusive or community relay.
* Just-in-time content generation: instead of storing a bunch of signed JSON and serving that to clients, there could be relays that store data in a more compact format and turn it into Nostr events at the time they receive a request from a Nostr client -- or relays that do some kind of live data generation based on who is connected, not storing anything.
* Community relays: some internet communities may want relays that restrict writing or browsing of content only to its members, essentially making it a closed group -- or it could be closed for outsiders to write, but public for them to read and vice-versa.
* Automated moderation schemes: relays that are owned by a group (either a static or a dynamic group) can rely on signals from their members, like mutes or reports, to decide what content to allow in its domains and what to disallow, making crowdfunded moderation easy.
* Curation: in the same way as community relays can deal with unwanted content, they can also perform curation based on signals from their members (for example, if a member of the relay likes some note from someone that is outside the relay that note can be fetched and stored), creating a dynamic relay that can be browsed by anyone that share the same interests as that community.
* Local relays: a relay that can be only browsed by people using the WiFi connection of some event or some building, serving as a way to share temporary or restricted content that only interests people sharing that circumstance.
* Cool experiments: relays that only allow one note per user per day, relays that require proof-of-work on event ids], relays that require engagement otherwise you get kicked, relays that return events in different ordering, relays that impose arbitrary funny rules on notes in order for them to be accepted (i.e. they must contain the word "poo"), I don't know!

26
ephemeral.go Normal file
View File

@@ -0,0 +1,26 @@
package khatru
import (
"context"
"errors"
"github.com/nbd-wtf/go-nostr"
)
func (rl *Relay) handleEphemeral(ctx context.Context, evt *nostr.Event) error {
for _, reject := range rl.RejectEvent {
if reject, msg := reject(ctx, evt); reject {
if msg == "" {
return errors.New("blocked: no reason")
} else {
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
}
for _, oee := range rl.OnEphemeralEvent {
oee(ctx, evt)
}
return nil
}

View File

@@ -20,6 +20,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
relay.Negentropy = true
fmt.Println("running on :3334")

View File

@@ -20,6 +20,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)

View File

@@ -13,7 +13,7 @@ func main() {
relay := khatru.NewRelay()
db := lmdb.LMDBBackend{Path: "/tmp/khatru-lmdb-tmp"}
os.MkdirAll(db.Path, 0755)
os.MkdirAll(db.Path, 0o755)
if err := db.Init(); err != nil {
panic(err)
}
@@ -22,6 +22,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)

View File

@@ -20,6 +20,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)

View File

@@ -20,6 +20,7 @@ func main() {
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)

View File

@@ -15,23 +15,27 @@ import (
func main() {
relay := khatru.NewRelay()
db := &badger.BadgerBackend{Path: "/tmp/khatru-badger-blossom-tmp"}
db := &badger.BadgerBackend{Path: "/tmp/khatru-badger-tmp"}
if err := db.Init(); err != nil {
panic(err)
}
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
bdb := &badger.BadgerBackend{Path: "/tmp/khatru-badger-blossom-tmp"}
if err := bdb.Init(); err != nil {
panic(err)
}
bl := blossom.New(relay, "http://localhost:3334")
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: db, ServiceURL: bl.ServiceURL}
bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, body []byte) error {
fmt.Println("storing", sha256, len(body))
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: bdb, ServiceURL: bl.ServiceURL}
bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, ext string, body []byte) error {
fmt.Println("storing", sha256, ext, len(body))
return nil
})
bl.LoadBlob = append(bl.LoadBlob, func(ctx context.Context, sha256 string) (io.Reader, error) {
bl.LoadBlob = append(bl.LoadBlob, func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, error) {
fmt.Println("loading", sha256)
blob := strings.NewReader("aaaaa")
return blob, nil

View File

@@ -16,7 +16,7 @@ func main() {
relay := khatru.NewRelay()
db := lmdb.LMDBBackend{Path: "/tmp/exclusive"}
os.MkdirAll(db.Path, 0755)
os.MkdirAll(db.Path, 0o755)
if err := db.Init(); err != nil {
panic(err)
}

View File

@@ -52,7 +52,7 @@ func main() {
return slices.Contains(filter.Kinds, 1) && slices.Contains(filter.Tags["t"], "spam")
}).
Event(func(event *nostr.Event) bool {
return event.Kind == 1 && event.Tags.GetFirst([]string{"t", "spam"}) != nil
return event.Kind == 1 && event.Tags.FindWithValue("t", "spam") != nil
}).
Relay(r2)

150
expiration.go Normal file
View File

@@ -0,0 +1,150 @@
package khatru
import (
"container/heap"
"context"
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip40"
)
type expiringEvent struct {
id string
expiresAt nostr.Timestamp
}
type expiringEventHeap []expiringEvent
func (h expiringEventHeap) Len() int { return len(h) }
func (h expiringEventHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt }
func (h expiringEventHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *expiringEventHeap) Push(x interface{}) {
*h = append(*h, x.(expiringEvent))
}
func (h *expiringEventHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
type expirationManager struct {
events expiringEventHeap
mu sync.Mutex
relay *Relay
interval time.Duration
initialScanDone bool
}
func newExpirationManager(relay *Relay) *expirationManager {
return &expirationManager{
events: make(expiringEventHeap, 0),
relay: relay,
interval: time.Hour,
}
}
func (em *expirationManager) start(ctx context.Context) {
ticker := time.NewTicker(em.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !em.initialScanDone {
em.initialScan(ctx)
em.initialScanDone = true
}
em.checkExpiredEvents(ctx)
}
}
}
func (em *expirationManager) initialScan(ctx context.Context) {
em.mu.Lock()
defer em.mu.Unlock()
// query all events
ctx = context.WithValue(ctx, internalCallKey, struct{}{})
for _, query := range em.relay.QueryEvents {
ch, err := query(ctx, nostr.Filter{})
if err != nil {
continue
}
for evt := range ch {
if expiresAt := nip40.GetExpiration(evt.Tags); expiresAt != -1 {
heap.Push(&em.events, expiringEvent{
id: evt.ID,
expiresAt: expiresAt,
})
}
}
}
heap.Init(&em.events)
}
func (em *expirationManager) checkExpiredEvents(ctx context.Context) {
em.mu.Lock()
defer em.mu.Unlock()
now := nostr.Now()
// keep deleting events from the heap as long as they're expired
for em.events.Len() > 0 {
next := em.events[0]
if now < next.expiresAt {
break
}
heap.Pop(&em.events)
ctx := context.WithValue(ctx, internalCallKey, struct{}{})
for _, query := range em.relay.QueryEvents {
ch, err := query(ctx, nostr.Filter{IDs: []string{next.id}})
if err != nil {
continue
}
if evt := <-ch; evt != nil {
for _, del := range em.relay.DeleteEvent {
del(ctx, evt)
}
}
break
}
}
}
func (em *expirationManager) trackEvent(evt *nostr.Event) {
if expiresAt := nip40.GetExpiration(evt.Tags); expiresAt != -1 {
em.mu.Lock()
heap.Push(&em.events, expiringEvent{
id: evt.ID,
expiresAt: expiresAt,
})
em.mu.Unlock()
}
}
func (em *expirationManager) removeEvent(id string) {
em.mu.Lock()
defer em.mu.Unlock()
// Find and remove the event from the heap
for i := 0; i < len(em.events); i++ {
if em.events[i].id == id {
heap.Remove(&em.events, i)
break
}
}
}

75
go.mod
View File

@@ -1,63 +1,72 @@
module github.com/fiatjaf/khatru
go 1.23.1
go 1.24.1
require (
github.com/bep/debounce v1.2.1
github.com/fasthttp/websocket v1.5.7
github.com/fiatjaf/eventstore v0.13.0
github.com/fasthttp/websocket v1.5.12
github.com/fiatjaf/eventstore v0.16.2
github.com/liamg/magic v0.0.1
github.com/nbd-wtf/go-nostr v0.42.0
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/mailru/easyjson v0.9.0
github.com/nbd-wtf/go-nostr v0.51.8
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/rs/cors v1.11.1
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
)
require (
fiatjaf.com/lib v0.2.0 // indirect
github.com/PowerDNS/lmdb-go v1.9.2 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/PowerDNS/lmdb-go v1.9.3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aquasecurity/esquery v0.2.0 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dgraph-io/badger/v4 v4.3.1 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgraph-io/badger/v4 v4.5.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/elastic-transport-go/v8 v8.3.0 // indirect
github.com/elastic/elastic-transport-go/v8 v8.6.0 // indirect
github.com/elastic/go-elasticsearch/v7 v7.17.10 // indirect
github.com/elastic/go-elasticsearch/v8 v8.10.1 // indirect
github.com/elastic/go-elasticsearch/v8 v8.16.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/greatroar/blobloom v0.8.0 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v24.12.23+incompatible // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.10 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.32.0 // indirect
google.golang.org/protobuf v1.36.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

206
go.sum
View File

@@ -1,13 +1,15 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PowerDNS/lmdb-go v1.9.2 h1:Cmgerh9y3ZKBZGz1irxSShhfmFyRUh+Zdk4cZk7ZJvU=
github.com/PowerDNS/lmdb-go v1.9.2/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aquasecurity/esquery v0.2.0 h1:9WWXve95TE8hbm3736WB7nS6Owl8UGDeu+0jiyE9ttA=
github.com/aquasecurity/esquery v0.2.0/go.mod h1:VU+CIFR6C+H142HHZf9RUkp4Eedpo9UrEKeCQHWf9ao=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
@@ -16,60 +18,65 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurT
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/badger/v4 v4.5.0 h1:TeJE3I1pIWLBjYhIYCA1+uxrjWEoJXImFBMEBVSm16g=
github.com/dgraph-io/badger/v4 v4.5.0/go.mod h1:ysgYmIeG8dS/E8kwxT7xHyc7MkmwNYLRoYnFbr7387A=
github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I=
github.com/dgraph-io/ristretto/v2 v2.1.0/go.mod h1:uejeqfYXpUomfse0+lO+13ATz4TypQYLJZzBSAemuB4=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elastic/elastic-transport-go/v8 v8.3.0 h1:DJGxovyQLXGr62e9nDMPSxRyWION0Bh6d9eCFBriiHo=
github.com/elastic/elastic-transport-go/v8 v8.3.0/go.mod h1:87Tcz8IVNe6rVSLdBux1o/PEItLtyabHU3naC7IoqKI=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/elastic/elastic-transport-go/v8 v8.6.0 h1:Y2S/FBjx1LlCv5m6pWAF2kDJAHoSjSRSJCApolgfthA=
github.com/elastic/elastic-transport-go/v8 v8.6.0/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk=
github.com/elastic/go-elasticsearch/v7 v7.6.0/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo=
github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
github.com/elastic/go-elasticsearch/v8 v8.10.1 h1:JJ3i2DimYTsJcUoEGbg6tNB0eehTNdid9c5kTR1TGuI=
github.com/elastic/go-elasticsearch/v8 v8.10.1/go.mod h1:GU1BJHO7WeamP7UhuElYwzzHtvf9SDmeVpSSy9+o6Qg=
github.com/elastic/go-elasticsearch/v8 v8.16.0 h1:f7bR+iBz8GTAVhwyFO3hm4ixsz2eMaEy0QroYnXV3jE=
github.com/elastic/go-elasticsearch/v8 v8.16.0/go.mod h1:lGMlgKIbYoRvay3xWBeKahAiJOgmFDsjZC39nmO3H64=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fiatjaf/eventstore v0.13.0 h1:60cE/oIUdVHoE6aOayjIyubiQIhMW6jezLjdvcl29Y4=
github.com/fiatjaf/eventstore v0.13.0/go.mod h1:XOl5B6WGBX1a0ww6s3WT94QVOmye/6zDTtyWHVtHQ5U=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/fiatjaf/eventstore v0.16.2 h1:h4rHwSwPcqAKqWUsAbYWUhDeSgm2Kp+PBkJc3FgBYu4=
github.com/fiatjaf/eventstore v0.16.2/go.mod h1:0gU8fzYO/bG+NQAVlHtJWOlt3JKKFefh5Xjj2d1dLIs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -80,10 +87,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v24.12.23+incompatible h1:ubBKR94NR4pXUCY/MUsRVzd9umNW7ht7EG9hHfS9FX8=
github.com/google/flatbuffers v24.12.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -92,112 +97,115 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/greatroar/blobloom v0.8.0 h1:I9RlEkfqK9/6f1v9mFmDYegDQ/x0mISCpiNpAm23Pt4=
github.com/greatroar/blobloom v0.8.0/go.mod h1:mjMJ1hh1wjGVfr93QIHJ6FfDNVrA0IELv8OvMHJxHKs=
github.com/jgroeneveld/schema v1.0.0 h1:J0E10CrOkiSEsw6dfb1IfrDJD14pf6QLVJ3tRPl/syI=
github.com/jgroeneveld/schema v1.0.0/go.mod h1:M14lv7sNMtGvo3ops1MwslaSYgDYxrSmbzWIQ0Mr5rs=
github.com/jgroeneveld/trial v2.0.0+incompatible h1:d59ctdgor+VqdZCAiUfVN8K13s0ALDioG5DWwZNtRuQ=
github.com/jgroeneveld/trial v2.0.0+incompatible/go.mod h1:I6INLW96EN8WysNBXUFI3M4RIC8ePg9ntAc/Wy+U/+M=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/nbd-wtf/go-nostr v0.42.0 h1:EofWfXEhKic9AYVf4RHuXZr+kKUZE2jVyJtJByNe1rE=
github.com/nbd-wtf/go-nostr v0.42.0/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.51.8 h1:CIoS+YqChcm4e1L1rfMZ3/mIwTz4CwApM2qx7MHNzmE=
github.com/nbd-wtf/go-nostr v0.51.8/go.mod h1:d6+DfvMWYG5pA3dmNMBJd6WCHVDDhkXbHqvfljf0Gzg=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8=
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -205,13 +213,7 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -231,11 +233,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -243,3 +244,4 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -6,14 +6,19 @@ import (
"encoding/hex"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unsafe"
"github.com/bep/debounce"
"github.com/fasthttp/websocket"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip42"
"github.com/nbd-wtf/go-nostr/nip45"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
"github.com/nbd-wtf/go-nostr/nip70"
"github.com/nbd-wtf/go-nostr/nip77"
"github.com/nbd-wtf/go-nostr/nip77/negentropy"
"github.com/puzpuzpuz/xsync/v3"
@@ -22,10 +27,6 @@ import (
// ServeHTTP implements http.Handler interface.
func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if rl.ServiceURL == "" {
rl.ServiceURL = getServiceBaseURL(r)
}
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
@@ -117,8 +118,10 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
onconnect(ctx)
}
smp := nostr.NewMessageParser()
for {
typ, message, err := ws.conn.ReadMessage()
typ, msgb, err := ws.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(
err,
@@ -128,7 +131,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
websocket.CloseAbnormalClosure, // 1006
4537, // some client seems to send many of these
) {
rl.Log.Printf("unexpected close error from %s: %v\n", r.Header.Get("X-Forwarded-For"), err)
rl.Log.Printf("unexpected close error from %s: %v\n", GetIPFromRequest(r), err)
}
ws.cancel()
return
@@ -139,15 +142,20 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
continue
}
go func(message []byte) {
envelope := nostr.ParseMessage(message)
if envelope == nil {
if !rl.Negentropy {
// stop silently
return
// this is safe because ReadMessage() will always create a new slice
message := unsafe.String(unsafe.SliceData(msgb), len(msgb))
// parse messages sequentially otherwise sonic breaks
envelope, err := smp.ParseMessage(message)
// then delegate to the goroutine
go func(message string) {
if err != nil {
if err == nostr.UnknownLabel && rl.Negentropy {
envelope = nip77.ParseNegMessage(message)
}
envelope = nip77.ParseNegMessage(message)
if envelope == nil {
ws.WriteJSON(nostr.NoticeEnvelope("failed to parse envelope: " + err.Error()))
return
}
}
@@ -170,28 +178,31 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
// check NIP-70 protected
for _, v := range env.Event.Tags {
if len(v) == 1 && v[0] == "-" {
msg := "must be published by event author"
authed := GetAuthed(ctx)
if authed == "" {
RequestAuth(ctx)
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "auth-required: " + msg,
})
return
}
if authed != env.Event.PubKey {
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "blocked: " + msg,
})
return
}
if nip70.IsProtected(env.Event) {
authed := GetAuthed(ctx)
if authed == "" {
RequestAuth(ctx)
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "auth-required: must be published by authenticated event author",
})
return
} else if authed != env.Event.PubKey {
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "blocked: must be published by event author",
})
return
}
} else if nip70.HasEmbeddedProtected(env.Event) {
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "blocked: can't repost nip70 protected",
})
return
}
srl := rl
@@ -206,9 +217,16 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
if env.Event.Kind == 5 {
// this always returns "blocked: " whenever it returns an error
writeErr = srl.handleDeleteRequest(ctx, &env.Event)
} else {
// this will also always return a prefixed reason
skipBroadcast, writeErr = srl.AddEvent(ctx, &env.Event)
}
if writeErr == nil {
if nostr.IsEphemeralKind(env.Event.Kind) {
// this will also always return a prefixed reason
writeErr = srl.handleEphemeral(ctx, &env.Event)
} else {
// this will also always return a prefixed reason
skipBroadcast, writeErr = srl.handleNormal(ctx, &env.Event)
}
}
var reason string
@@ -218,9 +236,20 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ovw(ctx, &env.Event)
}
if !skipBroadcast {
srl.notifyListeners(&env.Event)
n := srl.notifyListeners(&env.Event)
// the number of notified listeners matters in ephemeral events
if nostr.IsEphemeralKind(env.Event.Kind) {
if n == 0 && len(rl.OnEphemeralEvent) == 0 {
ok = false
reason = "mute: no one was listening for this"
} else {
reason = "broadcasted to " + strconv.Itoa(n) + " listeners"
}
}
}
} else {
ok = false
reason = writeErr.Error()
if strings.HasPrefix(reason, "auth-required:") {
RequestAuth(ctx)
@@ -228,20 +257,35 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: ok, Reason: reason})
case *nostr.CountEnvelope:
if rl.CountEvents == nil {
if rl.CountEvents == nil && rl.CountEventsHLL == nil {
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: "unsupported: this relay does not support NIP-45"})
return
}
var total int64
for _, filter := range env.Filters {
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(filter)
}
total += srl.handleCountRequest(ctx, ws, filter)
var hll *hyperloglog.HyperLogLog
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(env.Filter)
}
ws.WriteJSON(nostr.CountEnvelope{SubscriptionID: env.SubscriptionID, Count: &total})
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(env.Filter); offset != -1 {
total, hll = srl.handleCountRequestWithHLL(ctx, ws, env.Filter, offset)
} else {
total = srl.handleCountRequest(ctx, ws, env.Filter)
}
resp := nostr.CountEnvelope{
SubscriptionID: env.SubscriptionID,
Count: &total,
}
if hll != nil {
resp.HyperLogLog = hll.GetRegisters()
}
ws.WriteJSON(resp)
case *nostr.ReqEnvelope:
eose := sync.WaitGroup{}
eose.Add(len(env.Filters))
@@ -274,17 +318,15 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
go func() {
// when all events have been loaded from databases and dispatched
// we can cancel the context and fire the EOSE message
// when all events have been loaded from databases and dispatched we can fire the EOSE message
eose.Wait()
cancelReqCtx(nil)
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
}()
case *nostr.CloseEnvelope:
id := string(*env)
rl.removeListenerId(ws, id)
case *nostr.AuthEnvelope:
wsBaseUrl := strings.Replace(rl.ServiceURL, "http", "ws", 1)
wsBaseUrl := strings.Replace(rl.getBaseURL(r), "http", "ws", 1)
if pubkey, ok := nip42.ValidateAuthEvent(&env.Event, ws.Challenge, wsBaseUrl); ok {
ws.AuthedPublicKey = pubkey
ws.authLock.Lock()

View File

@@ -3,7 +3,6 @@ package khatru
import (
"net"
"net/http"
"strconv"
"strings"
"github.com/nbd-wtf/go-nostr"
@@ -14,28 +13,6 @@ func isOlder(previous, next *nostr.Event) bool {
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
}
func getServiceBaseURL(r *http.Request) string {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if host == "localhost" {
proto = "http"
} else if strings.Index(host, ":") != -1 {
// has a port number
proto = "http"
} else if _, err := strconv.Atoi(strings.ReplaceAll(host, ".", "")); err == nil {
// it's a naked IP
proto = "http"
} else {
proto = "https"
}
}
return proto + "://" + host
}
var privateMasks = func() []net.IPNet {
privateCIDRs := []string{
"127.0.0.0/8",

View File

@@ -132,15 +132,20 @@ func (rl *Relay) removeClientAndListeners(ws *WebSocket) {
delete(rl.clients, ws)
}
func (rl *Relay) notifyListeners(event *nostr.Event) {
// returns how many listeners were notified
func (rl *Relay) notifyListeners(event *nostr.Event) int {
count := 0
listenersloop:
for _, listener := range rl.listeners {
if listener.filter.Matches(event) {
for _, pb := range rl.PreventBroadcast {
if pb(listener.ws, event) {
return
continue listenersloop
}
}
listener.ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &listener.id, Event: *event})
count++
}
}
return count
}

View File

@@ -3,6 +3,7 @@ package khatru
import (
"encoding/json"
"net/http"
"strings"
)
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
@@ -20,6 +21,15 @@ func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
info.AddSupportedNIP(77)
}
// resolve relative icon and banner URLs against base URL
baseURL := rl.getBaseURL(r)
if info.Icon != "" && !strings.HasPrefix(info.Icon, "http://") && !strings.HasPrefix(info.Icon, "https://") {
info.Icon = strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(info.Icon, "/")
}
if info.Banner != "" && !strings.HasPrefix(info.Banner, "http://") && !strings.HasPrefix(info.Banner, "https://") {
info.Banner = strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(info.Banner, "/")
}
for _, ovw := range rl.OverwriteRelayInformation {
info = ovw(r.Context(), r, info)
}

View File

@@ -28,15 +28,21 @@ type RelayManagementAPI struct {
AllowEvent func(ctx context.Context, id string, reason string) error
BanEvent func(ctx context.Context, id string, reason string) error
ListBannedEvents func(ctx context.Context) ([]nip86.IDReason, error)
ListAllowedEvents func(ctx context.Context) ([]nip86.IDReason, error)
ChangeRelayName func(ctx context.Context, name string) error
ChangeRelayDescription func(ctx context.Context, desc string) error
ChangeRelayIcon func(ctx context.Context, icon string) error
AllowKind func(ctx context.Context, kind int) error
DisallowKind func(ctx context.Context, kind int) error
ListAllowedKinds func(ctx context.Context) ([]int, error)
ListDisAllowedKinds func(ctx context.Context) ([]int, error)
BlockIP func(ctx context.Context, ip net.IP, reason string) error
UnblockIP func(ctx context.Context, ip net.IP, reason string) error
ListBlockedIPs func(ctx context.Context) ([]nip86.IPReason, error)
Stats func(ctx context.Context) (nip86.Response, error)
GrantAdmin func(ctx context.Context, pubkey string, methods []string) error
RevokeAdmin func(ctx context.Context, pubkey string, methods []string) error
Generic func(ctx context.Context, request nip86.Request) (nip86.Response, error)
}
func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
@@ -65,19 +71,26 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
resp.Error = "missing auth"
goto respond
}
if evtj, err := base64.StdEncoding.DecodeString(spl[1]); err != nil {
evtj, err := base64.StdEncoding.DecodeString(spl[1])
if err != nil {
resp.Error = "invalid base64 auth"
goto respond
} else if err := json.Unmarshal(evtj, &evt); err != nil {
}
if err := json.Unmarshal(evtj, &evt); err != nil {
resp.Error = "invalid auth event json"
goto respond
} else if ok, _ := evt.CheckSignature(); !ok {
}
if ok, _ := evt.CheckSignature(); !ok {
resp.Error = "invalid auth event"
goto respond
} else if uTag := evt.Tags.GetFirst([]string{"u", ""}); uTag == nil || getServiceBaseURL(r) != (*uTag)[1] {
resp.Error = "invalid 'u' tag"
}
if uTag := evt.Tags.Find("u"); uTag == nil || nostr.NormalizeURL(rl.getBaseURL(r)) != nostr.NormalizeURL(uTag[1]) {
resp.Error = fmt.Sprintf("invalid 'u' tag, got '%s', expected '%s'",
nostr.NormalizeURL(rl.getBaseURL(r)), nostr.NormalizeURL(uTag[1]))
goto respond
} else if pht := evt.Tags.GetFirst([]string{"payload", hex.EncodeToString(payloadHash[:])}); pht == nil {
} else if pht := evt.Tags.FindWithValue("payload", hex.EncodeToString(payloadHash[:])); pht == nil {
resp.Error = "invalid auth event payload hash"
goto respond
} else if evt.CreatedAt < nostr.Now()-30 {
@@ -112,13 +125,18 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
methods := make([]string, 0, mat.NumField())
for i := 0; i < mat.NumField(); i++ {
field := mat.Field(i)
value := mav.Field(i).Interface()
// danger: this assumes the struct fields are appropriately named
methodName := strings.ToLower(field.Name)
if methodName == "rejectapicall" {
continue
}
// assign this only if the function was defined
if mav.Field(i).Interface() != nil {
methods[i] = methodName
if !reflect.ValueOf(value).IsNil() {
methods = append(methods, methodName)
}
}
resp.Result = methods
@@ -183,7 +201,7 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
case nip86.ListBannedEvents:
if rl.ManagementAPI.ListBannedEvents == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListEventsNeedingModeration(ctx); err != nil {
} else if result, err := rl.ManagementAPI.ListBannedEvents(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
@@ -260,8 +278,54 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
} else {
resp.Result = result
}
case nip86.Stats:
if rl.ManagementAPI.Stats == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.Stats(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.GrantAdmin:
if rl.ManagementAPI.GrantAdmin == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.GrantAdmin(ctx, thing.Pubkey, thing.AllowMethods); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.RevokeAdmin:
if rl.ManagementAPI.RevokeAdmin == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.RevokeAdmin(ctx, thing.Pubkey, thing.DisallowMethods); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListDisallowedKinds:
if rl.ManagementAPI.ListDisAllowedKinds == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListDisAllowedKinds(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.ListAllowedEvents:
if rl.ManagementAPI.ListAllowedEvents == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListAllowedEvents(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
default:
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
if rl.ManagementAPI.Generic == nil {
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
} else if result, err := rl.ManagementAPI.Generic(ctx, req); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
}
}

43
policies/blossom.go Normal file
View File

@@ -0,0 +1,43 @@
package policies
import (
"context"
"strings"
)
// RedirectGet returns a function that redirects to a specified URL template with the given status code.
// The URL template can include {sha256} and/or {extension} placeholders that will be replaced
// with the actual values. If neither placeholder is present, {sha256}.{extension} will be
// appended to the URL with proper forward slash handling.
func RedirectGet(urlTemplate string, statusCode int) func(context.Context, string, string) (url string, code int, err error) {
return func(ctx context.Context, sha256 string, extension string) (string, int, error) {
finalURL := urlTemplate
// Replace placeholders if they exist
hasSHA256Placeholder := strings.Contains(finalURL, "{sha256}")
hasExtensionPlaceholder := strings.Contains(finalURL, "{extension}")
if hasSHA256Placeholder {
finalURL = strings.Replace(finalURL, "{sha256}", sha256, -1)
}
if hasExtensionPlaceholder {
finalURL = strings.Replace(finalURL, "{extension}", extension, -1)
}
// If neither placeholder is present, append sha256.extension
if !hasSHA256Placeholder && !hasExtensionPlaceholder {
// Ensure URL ends with a forward slash
if !strings.HasSuffix(finalURL, "/") {
finalURL += "/"
}
finalURL += sha256
if extension != "" {
finalURL += "." + extension
}
}
return finalURL, statusCode, nil
}
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip70"
)
// PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject
@@ -107,3 +108,10 @@ func PreventTimestampsInTheFuture(threshold time.Duration) func(context.Context,
func RejectEventsWithBase64Media(ctx context.Context, evt *nostr.Event) (bool, string) {
return strings.Contains(evt.Content, "data:image/") || strings.Contains(evt.Content, "data:video/"), "event with base64 media"
}
func OnlyAllowNIP70ProtectedEvents(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if nip70.IsProtected(*event) {
return false, ""
}
return true, "blocked: we only accept events protected with the nip70 \"-\" tag"
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
@@ -18,6 +19,14 @@ func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, ms
return false, ""
}
// MustAuth requires all subscribers to be authenticated
func MustAuth(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if khatru.GetAuthed(ctx) == "" {
return true, "auth-required: all requests must be authenticated"
}
return false, ""
}
// NoEmptyFilters disallows filters that don't have at least a tag, a kind, an author or an id.
func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
c := len(filter.Kinds) + len(filter.IDs) + len(filter.Authors)

View File

@@ -2,7 +2,6 @@ package policies
import (
"context"
"slices"
"github.com/fiatjaf/khatru"

View File

@@ -29,6 +29,18 @@ func EventPubKeyRateLimiter(tokensPerInterval int, interval time.Duration, maxTo
}
}
func EventAuthedPubKeyRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
user := khatru.GetAuthed(ctx)
if user == "" {
return false, ""
}
return rl(user), "rate-limited: slow down, please"
}
}
func ConnectionRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(r *http.Request) bool {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)

View File

@@ -9,7 +9,7 @@ import (
func ApplySaneDefaults(relay *khatru.Relay) {
relay.RejectEvent = append(relay.RejectEvent,
RejectEventsWithBase64Media,
EventIPRateLimiter(2, time.Minute*3, 5),
EventIPRateLimiter(2, time.Minute*3, 10),
)
relay.RejectFilter = append(relay.RejectFilter,
@@ -18,6 +18,6 @@ func ApplySaneDefaults(relay *khatru.Relay) {
)
relay.RejectConnection = append(relay.RejectConnection,
ConnectionRateLimiter(1, time.Minute*5, 10),
ConnectionRateLimiter(1, time.Minute*5, 100),
)
}

View File

@@ -5,22 +5,27 @@ import (
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/fasthttp/websocket"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
)
func NewRelay() *Relay {
ctx := context.Background()
rl := &Relay{
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
Info: &nip11.RelayInformationDocument{
Software: "https://github.com/fiatjaf/khatru",
Version: "n/a",
SupportedNIPs: []int{1, 11, 42, 70, 86},
SupportedNIPs: []any{1, 11, 40, 42, 70, 86},
},
upgrader: websocket.Upgrader{
@@ -40,25 +45,30 @@ func NewRelay() *Relay {
MaxMessageSize: 512000,
}
rl.expirationManager = newExpirationManager(rl)
go rl.expirationManager.start(ctx)
return rl
}
type Relay struct {
// setting this variable overwrites the hackish workaround we do to try to figure out our own base URL
ServiceURL string
// hooks that will be called at various times
RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string)
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
StoreEvent []func(ctx context.Context, event *nostr.Event) error
ReplaceEvent []func(ctx context.Context, event *nostr.Event) error
DeleteEvent []func(ctx context.Context, event *nostr.Event) error
OnEventSaved []func(ctx context.Context, event *nostr.Event)
OnEphemeralEvent []func(ctx context.Context, event *nostr.Event)
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)
OverwriteCountFilter []func(ctx context.Context, filter *nostr.Filter)
QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error)
CountEventsHLL []func(ctx context.Context, filter nostr.Filter, offset int) (int64, *hyperloglog.HyperLogLog, error)
RejectConnection []func(r *http.Request) bool
OnConnect []func(ctx context.Context)
OnDisconnect []func(ctx context.Context)
@@ -103,4 +113,33 @@ type Relay struct {
PongWait time.Duration // Time allowed to read the next pong message from the peer.
PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait.
MaxMessageSize int64 // Maximum message size allowed from peer.
// NIP-40 expiration manager
expirationManager *expirationManager
}
func (rl *Relay) getBaseURL(r *http.Request) string {
if rl.ServiceURL != "" {
return rl.ServiceURL
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if host == "localhost" {
proto = "http"
} else if strings.Contains(host, ":") {
// has a port number
proto = "http"
} else if _, err := strconv.Atoi(strings.ReplaceAll(host, ".", "")); err == nil {
// it's a naked IP
proto = "http"
} else {
proto = "https"
}
}
return proto + "://" + host
}

392
relay_test.go Normal file
View File

@@ -0,0 +1,392 @@
package khatru
import (
"context"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/nbd-wtf/go-nostr"
)
func TestBasicRelayFunctionality(t *testing.T) {
// setup relay with in-memory store
relay := NewRelay()
store := slicestore.SliceStore{}
store.Init()
relay.StoreEvent = append(relay.StoreEvent, store.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, store.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, store.DeleteEvent)
// start test server
server := httptest.NewServer(relay)
defer server.Close()
// create test keys
sk1 := nostr.GeneratePrivateKey()
pk1, err := nostr.GetPublicKey(sk1)
if err != nil {
t.Fatalf("Failed to get public key 1: %v", err)
}
sk2 := nostr.GeneratePrivateKey()
pk2, err := nostr.GetPublicKey(sk2)
if err != nil {
t.Fatalf("Failed to get public key 2: %v", err)
}
// helper to create signed events
createEvent := func(sk string, kind int, content string, tags nostr.Tags) nostr.Event {
pk, err := nostr.GetPublicKey(sk)
if err != nil {
t.Fatalf("Failed to get public key: %v", err)
}
evt := nostr.Event{
PubKey: pk,
CreatedAt: nostr.Now(),
Kind: kind,
Tags: tags,
Content: content,
}
evt.Sign(sk)
return evt
}
// connect two test clients
url := "ws" + server.URL[4:]
client1, err := nostr.RelayConnect(context.Background(), url)
if err != nil {
t.Fatalf("failed to connect client1: %v", err)
}
defer client1.Close()
client2, err := nostr.RelayConnect(context.Background(), url)
if err != nil {
t.Fatalf("failed to connect client2: %v", err)
}
defer client2.Close()
// test 1: store and query events
t.Run("store and query events", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
evt1 := createEvent(sk1, 1, "hello world", nil)
err := client1.Publish(ctx, evt1)
if err != nil {
t.Fatalf("failed to publish event: %v", err)
}
// Query the event back
sub, err := client2.Subscribe(ctx, []nostr.Filter{{
Authors: []string{pk1},
Kinds: []int{1},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// Wait for event
select {
case env := <-sub.Events:
if env.ID != evt1.ID {
t.Errorf("got wrong event: %v", env.ID)
}
case <-ctx.Done():
t.Fatal("timeout waiting for event")
}
})
// test 2: live event subscription
t.Run("live event subscription", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Setup subscription first
sub, err := client1.Subscribe(ctx, []nostr.Filter{{
Authors: []string{pk2},
Kinds: []int{1},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// Publish event from client2
evt2 := createEvent(sk2, 1, "testing live events", nil)
err = client2.Publish(ctx, evt2)
if err != nil {
t.Fatalf("failed to publish event: %v", err)
}
// Wait for event on subscription
select {
case env := <-sub.Events:
if env.ID != evt2.ID {
t.Errorf("got wrong event: %v", env.ID)
}
case <-ctx.Done():
t.Fatal("timeout waiting for live event")
}
})
// test 3: event deletion
t.Run("event deletion", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Create an event to be deleted
evt3 := createEvent(sk1, 1, "delete me", nil)
err = client1.Publish(ctx, evt3)
if err != nil {
t.Fatalf("failed to publish event: %v", err)
}
// Create deletion event
delEvent := createEvent(sk1, 5, "deleting", nostr.Tags{{"e", evt3.ID}})
err = client1.Publish(ctx, delEvent)
if err != nil {
t.Fatalf("failed to publish deletion event: %v", err)
}
{
// Try to query the deleted event
sub, err := client2.Subscribe(ctx, []nostr.Filter{{
IDs: []string{evt3.ID},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// Should get EOSE without receiving the deleted event
gotEvent := false
DeletedLoop:
for {
select {
case <-sub.Events:
gotEvent = true
case <-sub.EndOfStoredEvents:
if gotEvent {
t.Error("should not have received deleted event")
}
break DeletedLoop
case <-ctx.Done():
t.Fatal("timeout waiting for EOSE")
}
}
}
{
// Try to query the deletion itself
sub, err := client2.Subscribe(ctx, []nostr.Filter{{
Kinds: []int{5},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// Should get EOSE without receiving the deleted event
gotEvent := false
DeletionLoop:
for {
select {
case <-sub.Events:
gotEvent = true
case <-sub.EndOfStoredEvents:
if !gotEvent {
t.Error("should have received deletion event")
}
break DeletionLoop
case <-ctx.Done():
t.Fatal("timeout waiting for EOSE")
}
}
}
})
// test 4: replaceable events
t.Run("replaceable events", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// create initial kind:0 event
evt1 := createEvent(sk1, 0, `{"name":"initial"}`, nil)
evt1.CreatedAt = 1000 // Set specific timestamp for testing
evt1.Sign(sk1)
err = client1.Publish(ctx, evt1)
if err != nil {
t.Fatalf("failed to publish initial event: %v", err)
}
// create newer event that should replace the first
evt2 := createEvent(sk1, 0, `{"name":"newer"}`, nil)
evt2.CreatedAt = 2000 // Newer timestamp
evt2.Sign(sk1)
err = client1.Publish(ctx, evt2)
if err != nil {
t.Fatalf("failed to publish newer event: %v", err)
}
// create older event that should not replace the current one
evt3 := createEvent(sk1, 0, `{"name":"older"}`, nil)
evt3.CreatedAt = 1500 // Older than evt2
evt3.Sign(sk1)
err = client1.Publish(ctx, evt3)
if err != nil {
t.Fatalf("failed to publish older event: %v", err)
}
// query to verify only the newest event exists
sub, err := client2.Subscribe(ctx, []nostr.Filter{{
Authors: []string{pk1},
Kinds: []int{0},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// should only get one event back (the newest one)
var receivedEvents []*nostr.Event
for {
select {
case env := <-sub.Events:
receivedEvents = append(receivedEvents, env)
case <-sub.EndOfStoredEvents:
if len(receivedEvents) != 1 {
t.Errorf("expected exactly 1 event, got %d", len(receivedEvents))
}
if len(receivedEvents) > 0 && receivedEvents[0].Content != `{"name":"newer"}` {
t.Errorf("expected newest event content, got %s", receivedEvents[0].Content)
}
return
case <-ctx.Done():
t.Fatal("timeout waiting for events")
}
}
})
// test 5: event expiration
t.Run("event expiration", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// create a new relay with shorter expiration check interval
relay := NewRelay()
relay.expirationManager.interval = 3 * time.Second // check every 3 seconds
store := slicestore.SliceStore{}
store.Init()
relay.StoreEvent = append(relay.StoreEvent, store.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, store.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, store.DeleteEvent)
// start test server
server := httptest.NewServer(relay)
defer server.Close()
// connect test client
url := "ws" + server.URL[4:]
client, err := nostr.RelayConnect(context.Background(), url)
if err != nil {
t.Fatalf("failed to connect client: %v", err)
}
defer client.Close()
// create event that expires in 2 seconds
expiration := strconv.FormatInt(int64(nostr.Now()+2), 10)
evt := createEvent(sk1, 1, "i will expire soon", nostr.Tags{{"expiration", expiration}})
err = client.Publish(ctx, evt)
if err != nil {
t.Fatalf("failed to publish event: %v", err)
}
// verify event exists initially
sub, err := client.Subscribe(ctx, []nostr.Filter{{
IDs: []string{evt.ID},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
// should get the event
select {
case env := <-sub.Events:
if env.ID != evt.ID {
t.Error("got wrong event")
}
case <-ctx.Done():
t.Fatal("timeout waiting for event")
}
sub.Unsub()
// wait for expiration check (>3 seconds)
time.Sleep(4 * time.Second)
// verify event no longer exists
sub, err = client.Subscribe(ctx, []nostr.Filter{{
IDs: []string{evt.ID},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
// should get EOSE without receiving the expired event
gotEvent := false
for {
select {
case <-sub.Events:
gotEvent = true
case <-sub.EndOfStoredEvents:
if gotEvent {
t.Error("should not have received expired event")
}
return
case <-ctx.Done():
t.Fatal("timeout waiting for EOSE")
}
}
})
// test 6: unauthorized deletion
t.Run("unauthorized deletion", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// create an event from client1
evt4 := createEvent(sk1, 1, "try to delete me", nil)
err = client1.Publish(ctx, evt4)
if err != nil {
t.Fatalf("failed to publish event: %v", err)
}
// Try to delete it with client2
delEvent := createEvent(sk2, 5, "trying to delete", nostr.Tags{{"e", evt4.ID}})
err = client2.Publish(ctx, delEvent)
if err == nil {
t.Fatalf("should have failed to publish deletion event: %v", err)
}
// Verify event still exists
sub, err := client1.Subscribe(ctx, []nostr.Filter{{
IDs: []string{evt4.ID},
}})
if err != nil {
t.Fatalf("failed to subscribe: %v", err)
}
defer sub.Unsub()
select {
case env := <-sub.Events:
if env.ID != evt4.ID {
t.Error("got wrong event")
}
case <-ctx.Done():
t.Fatal("event should still exist")
}
})
}

View File

@@ -6,6 +6,7 @@ import (
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
)
func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) error {
@@ -61,12 +62,7 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
}
func (rl *Relay) handleCountRequest(ctx context.Context, ws *WebSocket, filter nostr.Filter) int64 {
// overwrite the filter (for example, to eliminate some kinds or tags that we know we don't support)
for _, ovw := range rl.OverwriteCountFilter {
ovw(ctx, &filter)
}
// then check if we'll reject this filter
// check if we'll reject this filter
for _, reject := range rl.RejectCountFilter {
if rejecting, msg := reject(ctx, filter); rejecting {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
@@ -86,3 +82,38 @@ func (rl *Relay) handleCountRequest(ctx context.Context, ws *WebSocket, filter n
return subtotal
}
func (rl *Relay) handleCountRequestWithHLL(
ctx context.Context,
ws *WebSocket,
filter nostr.Filter,
offset int,
) (int64, *hyperloglog.HyperLogLog) {
// check if we'll reject this filter
for _, reject := range rl.RejectCountFilter {
if rejecting, msg := reject(ctx, filter); rejecting {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
return 0, nil
}
}
// run the functions to count (generally it will be just one)
var subtotal int64 = 0
var hll *hyperloglog.HyperLogLog
for _, countHLL := range rl.CountEventsHLL {
res, fhll, err := countHLL(ctx, filter, offset)
if err != nil {
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
}
subtotal += res
if fhll != nil {
if hll == nil {
hll = fhll
} else {
hll.Merge(fhll)
}
}
}
return subtotal, hll
}

View File

@@ -10,6 +10,7 @@ const (
wsKey = iota
subscriptionIdKey
nip86HeaderAuthKey
internalCallKey
)
func RequestAuth(ctx context.Context) {
@@ -40,6 +41,12 @@ func GetAuthed(ctx context.Context) string {
return ""
}
// IsInternalCall returns true when a call to QueryEvents, for example, is being made because of a deletion
// or expiration request.
func IsInternalCall(ctx context.Context) bool {
return ctx.Value(internalCallKey) != nil
}
func GetIP(ctx context.Context) string {
conn := GetConnection(ctx)
if conn == nil {

View File

@@ -33,12 +33,14 @@ type WebSocket struct {
func (ws *WebSocket) WriteJSON(any any) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteJSON(any)
err := ws.conn.WriteJSON(any)
ws.mutex.Unlock()
return err
}
func (ws *WebSocket) WriteMessage(t int, b []byte) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteMessage(t, b)
err := ws.conn.WriteMessage(t, b)
ws.mutex.Unlock()
return err
}