Compare commits

...

33 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
28 changed files with 606 additions and 187 deletions

176
adding.go
View File

@@ -11,34 +11,90 @@ 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
@@ -48,68 +104,54 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast
}
}
} 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("%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"))
}
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

@@ -25,7 +25,7 @@ func readAuthorization(r *http.Request) (*nostr.Event, error) {
if err := easyjson.Unmarshal(eventj, &evt); err != nil {
return nil, fmt.Errorf("broken event")
}
if evt.Kind != 24242 || len(evt.ID) != 64 || !evt.CheckID() {
if evt.Kind != 24242 || !evt.CheckID() {
return nil, fmt.Errorf("invalid event")
}
if ok, _ := evt.CheckSignature(); !ok {

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
}
@@ -59,7 +60,7 @@ func (bs BlossomServer) handleUpload(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
}
@@ -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)
@@ -119,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 {
@@ -135,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)
}
@@ -163,33 +174,58 @@ 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 {
// use unix epoch as the time if we can't find the descriptor
// as described in the http.ServeContent documentation
@@ -200,7 +236,11 @@ 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
}
}
@@ -227,6 +267,9 @@ func (bs BlossomServer) handleHasBlob(w http.ResponseWriter, r *http.Request) {
blossomError(w, "file not found", 404)
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) {
@@ -239,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
}
@@ -283,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
}
@@ -296,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
@@ -320,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
}
@@ -336,8 +390,8 @@ func (bs BlossomServer) handleReport(w http.ResponseWriter, r *http.Request) {
return
}
var evt *nostr.Event
if err := json.Unmarshal(body, evt); err != nil {
var evt nostr.Event
if err := json.Unmarshal(body, &evt); err != nil {
blossomError(w, "can't parse event", 400)
return
}
@@ -353,7 +407,7 @@ func (bs BlossomServer) handleReport(w http.ResponseWriter, r *http.Request) {
}
for _, rr := range bs.ReceiveReport {
if err := rr(r.Context(), evt); err != nil {
if err := rr(r.Context(), &evt); err != nil {
blossomError(w, "failed to receive report: "+err.Error(), 500)
return
}
@@ -361,6 +415,100 @@ func (bs BlossomServer) handleReport(w http.ResponseWriter, r *http.Request) {
}
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,17 +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.ReadSeeker, 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,
@@ -43,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

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

@@ -22,15 +22,15 @@ func main() {
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 {
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) (io.ReadSeeker, error) {
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) error {
bl.DeleteBlob = append(bl.DeleteBlob, func(ctx context.Context, sha256 string, ext string) error {
// delete the blob data
return nil
})
@@ -47,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:
@@ -90,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

@@ -31,11 +31,11 @@ func main() {
}
bl := blossom.New(relay, "http://localhost:3334")
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))
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.ReadSeeker, 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)

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

12
go.mod
View File

@@ -4,12 +4,11 @@ go 1.24.1
require (
github.com/bep/debounce v1.2.1
github.com/cloudwego/base64x v0.1.5
github.com/fasthttp/websocket v1.5.12
github.com/fiatjaf/eventstore v0.16.2
github.com/liamg/magic v0.0.1
github.com/mailru/easyjson v0.9.0
github.com/nbd-wtf/go-nostr v0.51.4
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
@@ -23,10 +22,11 @@ require (
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.1 // 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.4.0 // indirect
@@ -63,10 +63,10 @@ require (
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.15.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.31.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
)

20
go.sum
View File

@@ -18,8 +18,8 @@ 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.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
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=
@@ -31,8 +31,8 @@ github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCy
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=
@@ -129,8 +129,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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.4 h1:L4kteLUu/U9P1v1i1CP9wXR+n7D7V4aSCWs4hPFdwp0=
github.com/nbd-wtf/go-nostr v0.51.4/go.mod h1:raIUNOilCdhiVIqgwe+9enCtdXu1iuPjbLh1hO7wTqI=
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=
@@ -178,8 +178,8 @@ 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.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
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=
@@ -204,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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -17,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"
@@ -140,6 +142,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
continue
}
// 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
@@ -175,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
@@ -211,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
@@ -223,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)
@@ -240,14 +264,13 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var total int64
var hll *hyperloglog.HyperLogLog
uneligibleForHLL := false
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(env.Filter)
}
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(env.Filter); offset != -1 && !uneligibleForHLL {
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)

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

@@ -86,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 {
@@ -124,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
@@ -195,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

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

@@ -110,7 +110,7 @@ func RejectEventsWithBase64Media(ctx context.Context, evt *nostr.Event) (bool, s
}
func OnlyAllowNIP70ProtectedEvents(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if nip70.IsProtected(event) {
if nip70.IsProtected(*event) {
return false, ""
}
return true, "blocked: we only accept events protected with the nip70 \"-\" tag"

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

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