Compare commits

..

6 Commits

Author SHA1 Message Date
fiatjaf
9f99b9827a add notice about the new library. 2025-09-22 16:55:36 -03:00
clark
8d5fc324f7 fix(deleting): handle relay URLs with port numbers using SplitN 2025-09-22 16:40:15 -03:00
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
8 changed files with 72 additions and 32 deletions

View File

@@ -1,3 +1,11 @@
<div>
<p><b>This repository is in maintenance mode and adventurous programmers are encouraged to try <a href="https://pkg.go.dev/fiatjaf.com/nostr/khatru"><code>fiatjaf.com/nostr/khatru@master</code></a> instead.</b></p>
<p>The core codebase and functionality are the same, but the API breaks a little bit (for good reason) and there are some new features.</p>
</div>
---
# khatru, a relay framework [![docs badge](https://img.shields.io/badge/docs-reference-blue)](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) # khatru, a relay framework [![docs badge](https://img.shields.io/badge/docs-reference-blue)](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay)
[![Run Tests](https://github.com/fiatjaf/khatru/actions/workflows/test.yml/badge.svg)](https://github.com/fiatjaf/khatru/actions/workflows/test.yml) [![Run Tests](https://github.com/fiatjaf/khatru/actions/workflows/test.yml/badge.svg)](https://github.com/fiatjaf/khatru/actions/workflows/test.yml)

View File

@@ -125,13 +125,17 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
hash := sha256.Sum256(b) hash := sha256.Sum256(b)
hhash := hex.EncodeToString(hash[:]) hhash := hex.EncodeToString(hash[:])
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
// keep track of the blob descriptor // keep track of the blob descriptor
bd := BlobDescriptor{ bd := BlobDescriptor{
URL: bs.ServiceURL + "/" + hhash + ext, URL: bs.ServiceURL + "/" + hhash + ext,
SHA256: hhash, SHA256: hhash,
Size: len(b), Size: len(b),
Type: mime.TypeByExtension(ext), Type: mimeType,
Uploaded: nostr.Now(), Uploaded: nostr.Now(),
} }
if err := bs.Store.Keep(r.Context(), bd, auth.PubKey); err != nil { if err := bs.Store.Keep(r.Context(), bd, auth.PubKey); err != nil {
@@ -141,7 +145,7 @@ func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
// save actual blob // save actual blob
for _, sb := range bs.StoreBlob { 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) blossomError(w, "failed to save: "+err.Error(), 500)
return return
} }
@@ -182,19 +186,25 @@ func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
} }
} }
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 { for _, rg := range bs.RejectGet {
reject, reason, code := rg(r.Context(), auth, hhash) reject, reason, code := rg(r.Context(), auth, hhash, ext)
if reject { if reject {
blossomError(w, reason, code) blossomError(w, reason, code)
return return
} }
} }
var ext string
if len(spl) == 2 {
ext = spl[1]
}
if len(bs.RedirectGet) > 0 { if len(bs.RedirectGet) > 0 {
for _, redirect := range bs.RedirectGet { for _, redirect := range bs.RedirectGet {
redirectURL, code, err := redirect(r.Context(), hhash, ext) redirectURL, code, err := redirect(r.Context(), hhash, ext)
@@ -215,7 +225,7 @@ func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
} }
for _, lb := range bs.LoadBlob { for _, lb := range bs.LoadBlob {
reader, _ := lb(r.Context(), hhash) reader, _ := lb(r.Context(), hhash, ext)
if reader != nil { if reader != nil {
// use unix epoch as the time if we can't find the descriptor // use unix epoch as the time if we can't find the descriptor
// as described in the http.ServeContent documentation // as described in the http.ServeContent documentation
@@ -335,9 +345,20 @@ func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
return 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? // should we accept this delete?
for _, rd := range bs.RejectDelete { for _, rd := range bs.RejectDelete {
reject, reason, code := rd(r.Context(), auth, hhash) reject, reason, code := rd(r.Context(), auth, hhash, ext)
if reject { if reject {
blossomError(w, reason, code) blossomError(w, reason, code)
return return
@@ -353,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 // 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 { if bd, err := bs.Store.Get(r.Context(), hhash); err == nil && bd == nil {
for _, del := range bs.DeleteBlob { 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) blossomError(w, "failed to delete blob: "+err.Error(), 500)
return return
} }
@@ -442,13 +463,12 @@ func (bs BlossomServer) handleMirror(w http.ResponseWriter, r *http.Request) {
// get content type and extension // get content type and extension
var ext string var ext string
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if contentType != "" { if contentType != "" { // First try to get the extension from the Content-Type header
ext = getExtension(contentType) ext = getExtension(contentType)
} else { } else if ft, _ := magic.Lookup(b); ft != nil { // Else try to infer extension from the file content
// Try to detect from URL extension ext = "." + ft.Extension
if idx := strings.LastIndex(body.URL, "."); idx >= 0 { } else if idx := strings.LastIndex(body.URL, "."); idx >= 0 { // Else, try to get the extension from the URL
ext = body.URL[idx:] ext = body.URL[idx:]
}
} }
// run reject hooks // run reject hooks
@@ -477,7 +497,7 @@ func (bs BlossomServer) handleMirror(w http.ResponseWriter, r *http.Request) {
// store actual blob // store actual blob
for _, sb := range bs.StoreBlob { 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 blob: "+err.Error(), 500) blossomError(w, "failed to save blob: "+err.Error(), 500)
return return
} }

View File

@@ -14,16 +14,16 @@ type BlossomServer struct {
ServiceURL string ServiceURL string
Store BlobIndex Store BlobIndex
StoreBlob []func(ctx context.Context, sha256 string, body []byte) error StoreBlob []func(ctx context.Context, sha256 string, ext string, body []byte) error
LoadBlob []func(ctx context.Context, sha256 string) (io.ReadSeeker, error) LoadBlob []func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, error)
DeleteBlob []func(ctx context.Context, sha256 string) error DeleteBlob []func(ctx context.Context, sha256 string, ext string) error
ReceiveReport []func(ctx context.Context, reportEvt *nostr.Event) 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) 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) 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) 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 // ServerOption represents a functional option for configuring a BlossomServer

View File

@@ -19,7 +19,7 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
case "e": case "e":
f = nostr.Filter{IDs: []string{tag[1]}} f = nostr.Filter{IDs: []string{tag[1]}}
case "a": case "a":
spl := strings.Split(tag[1], ":") spl := strings.SplitN(tag[1], ":", 3)
if len(spl) != 3 { if len(spl) != 3 {
continue continue
} }

View File

@@ -22,15 +22,15 @@ func main() {
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: blobdb, ServiceURL: bl.ServiceURL} bl.Store = blossom.EventStoreBlobIndexWrapper{Store: blobdb, ServiceURL: bl.ServiceURL}
// implement the required storage functions // 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 // store the blob data somewhere
return nil 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 // load and return the blob data
return nil, nil 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 // delete the blob data
return nil return nil
}) })

View File

@@ -31,11 +31,11 @@ func main() {
} }
bl := blossom.New(relay, "http://localhost:3334") bl := blossom.New(relay, "http://localhost:3334")
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: bdb, 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 { bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, ext string, body []byte) error {
fmt.Println("storing", sha256, len(body)) fmt.Println("storing", sha256, ext, len(body))
return nil 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) fmt.Println("loading", sha256)
blob := strings.NewReader("aaaaa") blob := strings.NewReader("aaaaa")
return blob, nil return blob, nil

View File

@@ -201,7 +201,7 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
case nip86.ListBannedEvents: case nip86.ListBannedEvents:
if rl.ManagementAPI.ListBannedEvents == nil { if rl.ManagementAPI.ListBannedEvents == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName()) 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() resp.Error = err.Error()
} else { } else {
resp.Result = result resp.Result = result

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 { func ConnectionRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(r *http.Request) bool {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens) rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)