mirror of
https://github.com/fiatjaf/khatru.git
synced 2026-04-08 14:36:47 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4d81dde4 | ||
|
|
7bfdbb557c | ||
|
|
3f26a1f727 | ||
|
|
76ecf4f791 | ||
|
|
1498da09c8 | ||
|
|
3d4dd71510 | ||
|
|
582a74c000 | ||
|
|
bbcf948dd6 | ||
|
|
553d848362 | ||
|
|
2a80d4099d | ||
|
|
ad6635d86c | ||
|
|
c93441cd63 | ||
|
|
dc34dd7e90 | ||
|
|
a004f59187 | ||
|
|
a931a83370 | ||
|
|
1c15db2ca1 | ||
|
|
b617fea679 | ||
|
|
1d7bdccb3a | ||
|
|
92d1a5b671 | ||
|
|
7f878121fc | ||
|
|
a893dc2d2c | ||
|
|
91e7737ec1 |
46
blossom/authorization.go
Normal file
46
blossom/authorization.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func readAuthorization(r *http.Request) (*nostr.Event, error) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(token, "Nostr ") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
reader = bytes.NewReader([]byte(token)[6:])
|
||||
reader = base64.NewDecoder(base64.StdEncoding, reader)
|
||||
var evt nostr.Event
|
||||
err := json.NewDecoder(reader).Decode(&evt)
|
||||
|
||||
if err != nil || evt.Kind != 24242 || len(evt.ID) != 64 || !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", ""})
|
||||
if expirationTag == nil {
|
||||
return nil, fmt.Errorf("missing \"expiration\" tag")
|
||||
}
|
||||
expiration, _ := strconv.ParseInt((*expirationTag)[1], 10, 64)
|
||||
if nostr.Timestamp(expiration) < nostr.Now() {
|
||||
return nil, fmt.Errorf("event expired")
|
||||
}
|
||||
|
||||
return &evt, nil
|
||||
}
|
||||
26
blossom/blob.go
Normal file
26
blossom/blob.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
type BlobDescriptor struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int `json:"size"`
|
||||
Type string `json:"type"`
|
||||
Uploaded nostr.Timestamp `json:"uploaded"`
|
||||
|
||||
Owner string `json:"-"`
|
||||
}
|
||||
|
||||
type BlobIndex interface {
|
||||
Keep(ctx context.Context, blob BlobDescriptor, pubkey string) error
|
||||
List(ctx context.Context, pubkey string) (chan BlobDescriptor, error)
|
||||
Get(ctx context.Context, sha256 string) (*BlobDescriptor, error)
|
||||
Delete(ctx context.Context, sha256 string, pubkey string) error
|
||||
}
|
||||
|
||||
var _ BlobIndex = (*EventStoreBlobIndexWrapper)(nil)
|
||||
104
blossom/eventstorewrapper.go
Normal file
104
blossom/eventstorewrapper.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/fiatjaf/eventstore"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// EventStoreBlobIndexWrapper uses fake events to keep track of what blobs we have stored and who owns them
|
||||
type EventStoreBlobIndexWrapper struct {
|
||||
eventstore.Store
|
||||
|
||||
ServiceURL string
|
||||
}
|
||||
|
||||
func (es EventStoreBlobIndexWrapper) Keep(ctx context.Context, blob BlobDescriptor, pubkey string) error {
|
||||
ch, err := es.Store.QueryEvents(ctx, nostr.Filter{Authors: []string{pubkey}, Kinds: []int{24242}, Tags: nostr.TagMap{"x": []string{blob.SHA256}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if <-ch == nil {
|
||||
// doesn't exist, save
|
||||
evt := &nostr.Event{
|
||||
PubKey: pubkey,
|
||||
Kind: 24242,
|
||||
Tags: nostr.Tags{
|
||||
{"x", blob.SHA256},
|
||||
{"type", blob.Type},
|
||||
{"size", strconv.Itoa(blob.Size)},
|
||||
},
|
||||
CreatedAt: blob.Uploaded,
|
||||
}
|
||||
evt.ID = evt.GetID()
|
||||
es.Store.SaveEvent(ctx, evt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es EventStoreBlobIndexWrapper) List(ctx context.Context, pubkey string) (chan BlobDescriptor, error) {
|
||||
ech, err := es.Store.QueryEvents(ctx, nostr.Filter{Authors: []string{pubkey}, Kinds: []int{24242}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan BlobDescriptor)
|
||||
|
||||
go func() {
|
||||
for evt := range ech {
|
||||
ch <- es.parseEvent(evt)
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (es EventStoreBlobIndexWrapper) Get(ctx context.Context, sha256 string) (*BlobDescriptor, error) {
|
||||
ech, err := es.Store.QueryEvents(ctx, nostr.Filter{Tags: nostr.TagMap{"x": []string{sha256}}, Kinds: []int{24242}, Limit: 1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evt := <-ech
|
||||
if evt != nil {
|
||||
bd := es.parseEvent(evt)
|
||||
return &bd, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (es EventStoreBlobIndexWrapper) Delete(ctx context.Context, sha256 string, pubkey string) error {
|
||||
ech, err := es.Store.QueryEvents(ctx, nostr.Filter{Authors: []string{pubkey}, Tags: nostr.TagMap{"x": []string{sha256}}, Kinds: []int{24242}, Limit: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt := <-ech
|
||||
if evt != nil {
|
||||
return es.Store.DeleteEvent(ctx, evt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es EventStoreBlobIndexWrapper) parseEvent(evt *nostr.Event) BlobDescriptor {
|
||||
hhash := evt.Tags[0][1]
|
||||
mimetype := evt.Tags[1][1]
|
||||
ext := getExtension(mimetype)
|
||||
size, _ := strconv.Atoi(evt.Tags[2][1])
|
||||
|
||||
return BlobDescriptor{
|
||||
Owner: evt.PubKey,
|
||||
Uploaded: evt.CreatedAt,
|
||||
URL: es.ServiceURL + "/" + hhash + ext,
|
||||
SHA256: hhash,
|
||||
Type: mimetype,
|
||||
Size: size,
|
||||
}
|
||||
}
|
||||
336
blossom/handlers.go
Normal file
336
blossom/handlers.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/liamg/magic"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func (bs BlossomServer) handleUploadCheck(w http.ResponseWriter, r *http.Request) {
|
||||
auth, err := readAuthorization(r)
|
||||
if err != nil {
|
||||
blossomError(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if auth == nil {
|
||||
blossomError(w, "missing \"Authorization\" header", 400)
|
||||
return
|
||||
}
|
||||
if auth.Tags.GetFirst([]string{"t", "upload"}) == nil {
|
||||
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
|
||||
return
|
||||
}
|
||||
|
||||
mimetype := r.Header.Get("X-Content-Type")
|
||||
exts, _ := mime.ExtensionsByType(mimetype)
|
||||
var ext string
|
||||
if len(exts) > 0 {
|
||||
ext = exts[0]
|
||||
}
|
||||
|
||||
// get the file size from the incoming header
|
||||
size, _ := strconv.Atoi(r.Header.Get("X-Content-Length"))
|
||||
|
||||
for _, rb := range bs.RejectUpload {
|
||||
reject, reason, code := rb(r.Context(), auth, size, ext)
|
||||
if reject {
|
||||
blossomError(w, reason, code)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
auth, err := readAuthorization(r)
|
||||
if err != nil {
|
||||
blossomError(w, "invalid \"Authorization\": "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if auth == nil {
|
||||
blossomError(w, "missing \"Authorization\" header", 400)
|
||||
return
|
||||
}
|
||||
if auth.Tags.GetFirst([]string{"t", "upload"}) == nil {
|
||||
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
|
||||
return
|
||||
}
|
||||
|
||||
// get the file size from the incoming header
|
||||
size, _ := strconv.Atoi(r.Header.Get("Content-Length"))
|
||||
if size == 0 {
|
||||
blossomError(w, "missing \"Content-Length\" header", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
blossomError(w, "failed to read initial bytes of upload body: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
var ext string
|
||||
if ft, _ := magic.Lookup(b); ft != nil {
|
||||
ext = "." + ft.Extension
|
||||
} else {
|
||||
// if we can't find, use the filetype given by the upload header
|
||||
mimetype := r.Header.Get("Content-Type")
|
||||
ext = getExtension(mimetype)
|
||||
}
|
||||
|
||||
// run the reject hooks
|
||||
for _, ru := range bs.RejectUpload {
|
||||
reject, reason, code := ru(r.Context(), auth, size, ext)
|
||||
if reject {
|
||||
blossomError(w, reason, code)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// if it passes then we have to read the entire thing into memory so we can compute the sha256
|
||||
for {
|
||||
var n int
|
||||
n, err = r.Body.Read(b[len(b):cap(b)])
|
||||
b = b[:len(b)+n]
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(b) == cap(b) {
|
||||
// add more capacity (let append pick how much)
|
||||
// if Content-Length was correct we shouldn't reach this
|
||||
b = append(b, 0)[:len(b)]
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
blossomError(w, "failed to read upload body: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(b)
|
||||
hhash := hex.EncodeToString(hash[:])
|
||||
|
||||
// keep track of the blob descriptor
|
||||
bd := BlobDescriptor{
|
||||
URL: bs.ServiceURL + "/" + hhash + ext,
|
||||
SHA256: hhash,
|
||||
Size: len(b),
|
||||
Type: mime.TypeByExtension(ext),
|
||||
Uploaded: nostr.Now(),
|
||||
}
|
||||
if err := bs.Store.Keep(r.Context(), bd, auth.PubKey); err != nil {
|
||||
blossomError(w, "failed to save event: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// save actual blob
|
||||
for _, sb := range bs.StoreBlob {
|
||||
if err := sb(r.Context(), hhash, b); err != nil {
|
||||
blossomError(w, "failed to save: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// return response
|
||||
json.NewEncoder(w).Encode(bd)
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
spl := strings.SplitN(r.URL.Path, ".", 2)
|
||||
hhash := spl[0]
|
||||
if len(hhash) != 65 {
|
||||
blossomError(w, "invalid /<sha256>[.ext] path", 400)
|
||||
return
|
||||
}
|
||||
hhash = hhash[1:]
|
||||
|
||||
// check for an authorization tag, if any
|
||||
auth, err := readAuthorization(r)
|
||||
if err != nil {
|
||||
blossomError(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// if there is one, we check if it has the extra requirements
|
||||
if auth != nil {
|
||||
if auth.Tags.GetFirst([]string{"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 {
|
||||
blossomError(w, "invalid \"Authorization\" event \"x\" or \"server\" tag", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, rg := range bs.RejectGet {
|
||||
reject, reason, code := rg(r.Context(), auth, hhash)
|
||||
if reject {
|
||||
blossomError(w, reason, code)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var ext string
|
||||
if len(spl) == 2 {
|
||||
ext = "." + spl[1]
|
||||
}
|
||||
|
||||
for _, lb := range bs.LoadBlob {
|
||||
reader, _ := lb(r.Context(), hhash)
|
||||
if reader != nil {
|
||||
// use unix epoch as the time if we can't find the descriptor
|
||||
// as described in the http.ServeContent documentation
|
||||
t := time.Unix(0, 0)
|
||||
descriptor, err := bs.Store.Get(r.Context(), hhash)
|
||||
if err == nil && descriptor != nil {
|
||||
t = descriptor.Uploaded.Time()
|
||||
}
|
||||
http.ServeContent(w, r, hhash+ext, t, reader)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
blossomError(w, "file not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleHasBlob(w http.ResponseWriter, r *http.Request) {
|
||||
spl := strings.SplitN(r.URL.Path, ".", 2)
|
||||
hhash := spl[0]
|
||||
if len(hhash) != 65 {
|
||||
blossomError(w, "invalid /<sha256>[.ext] path", 400)
|
||||
return
|
||||
}
|
||||
hhash = hhash[1:]
|
||||
|
||||
bd, err := bs.Store.Get(r.Context(), hhash)
|
||||
if err != nil {
|
||||
blossomError(w, "failed to query: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if bd == nil {
|
||||
blossomError(w, "file not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
// check for an authorization tag, if any
|
||||
auth, err := readAuthorization(r)
|
||||
if err != nil {
|
||||
blossomError(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// if there is one, we check if it has the extra requirements
|
||||
if auth != nil {
|
||||
if auth.Tags.GetFirst([]string{"t", "list"}) == nil {
|
||||
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pubkey := r.URL.Path[6:]
|
||||
|
||||
for _, rl := range bs.RejectList {
|
||||
reject, reason, code := rl(r.Context(), auth, pubkey)
|
||||
if reject {
|
||||
blossomError(w, reason, code)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ch, err := bs.Store.List(r.Context(), pubkey)
|
||||
if err != nil {
|
||||
blossomError(w, "failed to query: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte{'['})
|
||||
enc := json.NewEncoder(w)
|
||||
first := true
|
||||
for bd := range ch {
|
||||
if !first {
|
||||
w.Write([]byte{','})
|
||||
} else {
|
||||
first = false
|
||||
}
|
||||
enc.Encode(bd)
|
||||
}
|
||||
w.Write([]byte{']'})
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
auth, err := readAuthorization(r)
|
||||
if err != nil {
|
||||
blossomError(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if auth.Tags.GetFirst([]string{"t", "delete"}) == nil {
|
||||
blossomError(w, "invalid \"Authorization\" event \"t\" tag", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
spl := strings.SplitN(r.URL.Path, ".", 2)
|
||||
hhash := spl[0]
|
||||
if len(hhash) != 65 {
|
||||
blossomError(w, "invalid /<sha256>[.ext] path", 400)
|
||||
return
|
||||
}
|
||||
hhash = hhash[1:]
|
||||
if auth.Tags.GetFirst([]string{"x", hhash}) == nil &&
|
||||
auth.Tags.GetFirst([]string{"server", bs.ServiceURL}) == nil {
|
||||
blossomError(w, "invalid \"Authorization\" event \"x\" or \"server\" tag", 403)
|
||||
return
|
||||
}
|
||||
|
||||
// should we accept this delete?
|
||||
for _, rd := range bs.RejectDelete {
|
||||
reject, reason, code := rd(r.Context(), auth, hhash)
|
||||
if reject {
|
||||
blossomError(w, reason, code)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// delete the entry that links this blob to this author
|
||||
if err := bs.Store.Delete(r.Context(), hhash, auth.PubKey); err != nil {
|
||||
blossomError(w, "delete of blob entry failed: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
blossomError(w, "failed to delete blob: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleMirror(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (bs BlossomServer) handleNegentropy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
70
blossom/server.go
Normal file
70
blossom/server.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func New(rl *khatru.Relay, serviceURL string) *BlossomServer {
|
||||
bs := &BlossomServer{
|
||||
ServiceURL: serviceURL,
|
||||
}
|
||||
|
||||
base := rl.Router()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/upload" {
|
||||
if r.Method == "PUT" {
|
||||
bs.handleUpload(w, r)
|
||||
return
|
||||
} else if r.Method == "HEAD" {
|
||||
bs.handleUploadCheck(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 r.Method == "HEAD" {
|
||||
bs.handleHasBlob(w, r)
|
||||
return
|
||||
} else if r.Method == "GET" {
|
||||
bs.handleGetBlob(w, r)
|
||||
return
|
||||
} else if r.Method == "DELETE" {
|
||||
bs.handleDelete(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
base.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
rl.SetRouter(mux)
|
||||
|
||||
return bs
|
||||
}
|
||||
37
blossom/utils.go
Normal file
37
blossom/utils.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func blossomError(w http.ResponseWriter, msg string, code int) {
|
||||
w.Header().Add("X-Reason", msg)
|
||||
w.WriteHeader(code)
|
||||
}
|
||||
|
||||
func getExtension(mimetype string) string {
|
||||
if mimetype == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch mimetype {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
}
|
||||
|
||||
exts, _ := mime.ExtensionsByType(mimetype)
|
||||
if len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
42
examples/blossom/main.go
Normal file
42
examples/blossom/main.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fiatjaf/eventstore/badger"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/khatru/blossom"
|
||||
)
|
||||
|
||||
func main() {
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
db := &badger.BadgerBackend{Path: "/tmp/khatru-badger-blossom-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)
|
||||
|
||||
bl := blossom.New(relay, "http://localhost:3334")
|
||||
bl.Store = blossom.EventStoreBlobIndexWrapper{Store: db, ServiceURL: bl.ServiceURL}
|
||||
bl.StoreBlob = append(bl.StoreBlob, func(ctx context.Context, sha256 string, body []byte) error {
|
||||
fmt.Println("storing", sha256, len(body))
|
||||
return nil
|
||||
})
|
||||
bl.LoadBlob = append(bl.LoadBlob, func(ctx context.Context, sha256 string) (io.ReadSeeker, error) {
|
||||
fmt.Println("loading", sha256)
|
||||
blob := strings.NewReader("aaaaa")
|
||||
return blob, nil
|
||||
})
|
||||
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
}
|
||||
@@ -15,6 +15,10 @@ func (rl *Relay) Router() *http.ServeMux {
|
||||
return rl.serveMux
|
||||
}
|
||||
|
||||
func (rl *Relay) SetRouter(mux *http.ServeMux) {
|
||||
rl.serveMux = mux
|
||||
}
|
||||
|
||||
// Start creates an http server and starts listening on given host and port.
|
||||
func (rl *Relay) Start(host string, port int, started ...chan bool) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
7
go.mod
7
go.mod
@@ -5,10 +5,11 @@ go 1.23.1
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/fasthttp/websocket v1.5.7
|
||||
github.com/fiatjaf/eventstore v0.12.0
|
||||
github.com/nbd-wtf/go-nostr v0.40.0
|
||||
github.com/fiatjaf/eventstore v0.13.0
|
||||
github.com/liamg/magic v0.0.1
|
||||
github.com/nbd-wtf/go-nostr v0.42.0
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
github.com/rs/cors v1.7.0
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
)
|
||||
|
||||
|
||||
14
go.sum
14
go.sum
@@ -53,8 +53,8 @@ github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KH
|
||||
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
|
||||
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.12.0 h1:ZdL+dZkIgBgIp5A3+3XLdPg/uucv5Tiws6DHzNfZG4M=
|
||||
github.com/fiatjaf/eventstore v0.12.0/go.mod h1:PxeYbZ3MsH0XLobANsp6c0cJjJYkfmBJ3TwrplFy/08=
|
||||
github.com/fiatjaf/eventstore v0.13.0 h1:60cE/oIUdVHoE6aOayjIyubiQIhMW6jezLjdvcl29Y4=
|
||||
github.com/fiatjaf/eventstore v0.13.0/go.mod h1:XOl5B6WGBX1a0ww6s3WT94QVOmye/6zDTtyWHVtHQ5U=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
@@ -111,6 +111,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
|
||||
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
@@ -119,8 +121,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/nbd-wtf/go-nostr v0.40.0 h1:ea7FlOsm4kO1071Tm4OT0lXTcyleiZCT9Ll4XERjTZw=
|
||||
github.com/nbd-wtf/go-nostr v0.40.0/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick=
|
||||
github.com/nbd-wtf/go-nostr v0.42.0 h1:EofWfXEhKic9AYVf4RHuXZr+kKUZE2jVyJtJByNe1rE=
|
||||
github.com/nbd-wtf/go-nostr v0.42.0/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick=
|
||||
github.com/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=
|
||||
@@ -132,6 +134,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
|
||||
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||
@@ -235,5 +239,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
|
||||
20
handlers.go
20
handlers.go
@@ -26,14 +26,28 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rl.ServiceURL = getServiceBaseURL(r)
|
||||
}
|
||||
|
||||
corsMiddleware := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{
|
||||
http.MethodHead,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
},
|
||||
AllowedHeaders: []string{"Authorization", "*"},
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
rl.HandleWebsocket(w, r)
|
||||
} else if r.Header.Get("Accept") == "application/nostr+json" {
|
||||
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
|
||||
corsMiddleware.Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
|
||||
} else if r.Header.Get("Content-Type") == "application/nostr+json+rpc" {
|
||||
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r)
|
||||
corsMiddleware.Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r)
|
||||
} else {
|
||||
rl.serveMux.ServeHTTP(w, r)
|
||||
corsMiddleware.Handler(rl.serveMux).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fiatjaf/eventstore"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip77/negentropy"
|
||||
"github.com/nbd-wtf/go-nostr/nip77/negentropy/storage/vector"
|
||||
@@ -16,6 +17,8 @@ type NegentropySession struct {
|
||||
}
|
||||
|
||||
func (rl *Relay) startNegentropySession(ctx context.Context, filter nostr.Filter) (*vector.Vector, error) {
|
||||
ctx = eventstore.SetNegentropy(ctx)
|
||||
|
||||
// do the same overwrite/reject flow we do in normal REQs
|
||||
for _, ovw := range rl.OverwriteFilter {
|
||||
ovw(ctx, &filter)
|
||||
|
||||
@@ -72,7 +72,7 @@ func RestrictToSpecifiedKinds(allowEphemeral bool, kinds ...uint16) func(context
|
||||
slices.Sort(kinds)
|
||||
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if allowEphemeral && event.IsEphemeral() {
|
||||
if allowEphemeral && nostr.IsEphemeralKind(event.Kind) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user