Compare commits

...

47 Commits

Author SHA1 Message Date
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
31 changed files with 793 additions and 267 deletions

178
adding.go
View File

@@ -11,105 +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
}
target := <-ch
if target == nil {
continue
}
return true, errors.New("blocked: this event has been deleted")
}
// will store
// regular kinds are just saved directly
if nostr.IsRegularKind(evt.Kind) {
for _, store := range rl.StoreEvent {
if err := store(ctx, evt); err != nil {
switch err {
case eventstore.ErrDupEvent:
return true, nil
default:
return false, fmt.Errorf("%s", nostr.NormalizeOKMessage(err.Error(), "error"))
}
}
}
} else {
// will store
// regular kinds are just saved directly
if nostr.IsRegularKind(evt.Kind) {
for _, store := range rl.StoreEvent {
if err := store(ctx, evt); err != nil {
// 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
}
}
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(nostr.NormalizeOKMessage(err.Error(), "error"))
return false, fmt.Errorf("%s", nostr.NormalizeOKMessage(err.Error(), "error"))
}
}
}
} else {
// 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 {
// 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(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(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
}
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)
}
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,6 +7,7 @@ import (
"io"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -25,7 +26,7 @@ func (bs BlossomServer) handleUploadCheck(w http.ResponseWriter, r *http.Request
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
}
@@ -52,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", 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
}
@@ -73,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
}
@@ -86,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)
@@ -142,6 +148,7 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
}
// return response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bd)
}
@@ -163,13 +170,13 @@ 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
}
@@ -185,7 +192,26 @@ func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
var ext string
if len(spl) == 2 {
ext = "." + spl[1]
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 {
@@ -200,13 +226,16 @@ func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("ETag", hhash)
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, hhash+ext, t, reader)
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) {
@@ -228,8 +257,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) {
@@ -242,7 +272,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
}
@@ -286,7 +316,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
}
@@ -299,8 +329,8 @@ 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
}
@@ -331,7 +361,134 @@ 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 != "" {
ext = getExtension(contentType)
} else {
// Try to detect from URL extension
if idx := strings.LastIndex(body.URL, "."); idx >= 0 {
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, 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,9 +14,11 @@ 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.ReadSeeker, error)
DeleteBlob []func(ctx context.Context, sha256 string) error
StoreBlob []func(ctx context.Context, sha256 string, body []byte) error
LoadBlob []func(ctx context.Context, sha256 string) (io.ReadSeeker, error)
DeleteBlob []func(ctx context.Context, sha256 string) error
ReceiveReport []func(ctx context.Context, reportEvt *nostr.Event) error
RedirectGet []func(ctx context.Context, sha256 string, fileExtension 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)
@@ -24,6 +26,11 @@ type BlossomServer struct {
RejectDelete []func(ctx context.Context, auth *nostr.Event, sha256 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

@@ -19,7 +19,7 @@ export default {
{ text: 'HTTP Integration', link: '/core/embed' },
{ text: 'Request Routing', link: '/core/routing' },
{ text: 'Management API', link: '/core/management' },
{ text: 'Media Storage', link: '/core/blossom' },
{ text: 'Media Storage (Blossom)', link: '/core/blossom' },
]
},
{

View File

@@ -18,7 +18,8 @@ func main() {
bl := blossom.New(relay, "http://localhost:3334")
// create a database for keeping track of blob metadata
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: db, ServiceURL: bl.ServiceURL}
// (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, body []byte) error {
@@ -46,6 +47,44 @@ You can integrate any storage backend by implementing the three core functions:
- `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:
@@ -89,4 +128,4 @@ bl.Store = blossom.EventStoreBlobIndexWrapper{
}
```
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. Jut avoid reusing the same datastore that is used for the actual relay events unless you know what you're doing.
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

@@ -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

@@ -4,6 +4,8 @@ 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

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

@@ -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)
}

View File

@@ -15,19 +15,22 @@ 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.Store = blossom.EventStoreBlobIndexWrapper{Store: bdb, ServiceURL: bl.ServiceURL}
bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, body []byte) error {
fmt.Println("storing", sha256, len(body))
return 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)

View File

@@ -73,6 +73,7 @@ func (em *expirationManager) initialScan(ctx context.Context) {
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 {
@@ -107,6 +108,7 @@ func (em *expirationManager) checkExpiredEvents(ctx context.Context) {
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 {
@@ -133,3 +135,16 @@ func (em *expirationManager) trackEvent(evt *nostr.Event) {
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
}
}
}

47
go.mod
View File

@@ -1,32 +1,37 @@
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.15.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.46.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.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/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/coder/websocket v1.8.12 // 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/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.0.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.6.0 // indirect
github.com/elastic/go-elasticsearch/v7 v7.17.10 // indirect
@@ -35,31 +40,33 @@ require (
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.3.25+incompatible // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // 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.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // 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/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
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/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
golang.org/x/net v0.32.0 // indirect
golang.org/x/sys v0.28.0 // indirect
google.golang.org/protobuf v1.35.2 // 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
)

99
go.sum
View File

@@ -4,10 +4,12 @@ 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/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,28 +18,37 @@ 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/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.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
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/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.0.0 h1:l0yiSOtlJvc0otkqyMaDNysg8E9/F/TYZwMbxscNOAQ=
github.com/dgraph-io/ristretto/v2 v2.0.0/go.mod h1:FVFokF2dRqXyPyeMnK1YDy8Fc6aTe0IKgbcd03CYeEk=
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/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=
@@ -49,12 +60,12 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
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.15.0 h1:5UXe0+vIb30/cYcOWipks8nR3g+X8W224TFy5yPzivk=
github.com/fiatjaf/eventstore v0.15.0/go.mod h1:KAsld5BhkmSck48aF11Txu8X+OGNmoabw4TlYVWqInc=
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=
@@ -76,8 +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/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=
@@ -98,38 +109,45 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
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.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/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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
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.46.0 h1:aR+xXEC6MPutNMIRhNdi+2iBPEHW7SO10sFaOAVSz3Y=
github.com/nbd-wtf/go-nostr v0.46.0/go.mod h1:xVNOqkn0GImeTmaF6VDwgYsuSkfG3yrIbd0dT6NZDIQ=
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/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/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=
@@ -142,10 +160,14 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
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/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=
@@ -156,11 +178,13 @@ go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZ
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-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-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
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=
@@ -170,8 +194,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
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-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
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=
@@ -180,8 +204,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.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=
@@ -209,8 +233,8 @@ 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.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -220,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,9 +6,11 @@ import (
"encoding/hex"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unsafe"
"github.com/bep/debounce"
"github.com/fasthttp/websocket"
@@ -16,6 +18,7 @@ import (
"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"
@@ -115,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,
@@ -126,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
@@ -137,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
}
}
@@ -168,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
@@ -204,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
@@ -216,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)
@@ -233,34 +264,16 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var total int64
var hll *hyperloglog.HyperLogLog
uneligibleForHLL := false
for _, filter := range env.Filters {
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(filter)
}
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(env.Filter)
}
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && !uneligibleForHLL {
partial, phll := srl.handleCountRequestWithHLL(ctx, ws, filter, offset)
if phll != nil {
if hll == nil {
// in the first iteration (which should be the only case of the times)
// we optimize slightly by assigning instead of merging
hll = phll
} else {
hll.Merge(phll)
}
} else {
// if any of the filters is uneligible then we will discard previous HLL results
// and refuse to do HLL at all anymore for this query
uneligibleForHLL = true
hll = nil
}
total += partial
} else {
total += srl.handleCountRequest(ctx, ws, filter)
}
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{
@@ -305,10 +318,8 @@ 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:

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) {
@@ -80,10 +86,11 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
goto respond
}
if uTag := evt.Tags.GetFirst([]string{"u", ""}); uTag == nil || rl.getBaseURL(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 {
@@ -118,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
@@ -266,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

@@ -131,7 +131,7 @@ func (rl *Relay) getBaseURL(r *http.Request) string {
if proto == "" {
if host == "localhost" {
proto = "http"
} else if strings.Index(host, ":") != -1 {
} else if strings.Contains(host, ":") {
// has a port number
proto = "http"
} else if _, err := strconv.Atoi(strings.ReplaceAll(host, ".", "")); err == nil {

View File

@@ -151,33 +151,64 @@ func TestBasicRelayFunctionality(t *testing.T) {
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()
{
// 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
for {
select {
case <-sub.Events:
gotEvent = true
case <-sub.EndOfStoredEvents:
if gotEvent {
t.Error("should not have received deleted event")
// 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")
}
return
case <-ctx.Done():
t.Fatal("timeout waiting for EOSE")
}
}
})
// test 4: teplaceable events
// test 4: replaceable events
t.Run("replaceable events", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

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
}