basic modular blossom support.

This commit is contained in:
fiatjaf 2024-10-27 17:20:10 -03:00
parent 1dc12e5d2e
commit 91e7737ec1
9 changed files with 416 additions and 0 deletions

48
blossom/authorization.go Normal file
View File

@ -0,0 +1,48 @@
package blossom
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/nbd-wtf/go-nostr"
)
var errMissingHeader = fmt.Errorf("missing header")
func readAuthorization(r *http.Request) (*nostr.Event, error) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Nostr ") {
return nil, errMissingHeader
}
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
}

11
blossom/blob.go Normal file
View File

@ -0,0 +1,11 @@
package blossom
import "github.com/nbd-wtf/go-nostr"
type Blob struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int `json:"size"`
Type string `json:"type"`
Uploaded nostr.Timestamp `json:"uploaded"`
}

231
blossom/handlers.go Normal file
View File

@ -0,0 +1,231 @@
package blossom
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"mime"
"net/http"
"strings"
"github.com/liamg/magic"
"github.com/nbd-wtf/go-nostr"
)
func (bs BlossomServer) handleUpload(w http.ResponseWriter, r *http.Request) {
auth, err := readAuthorization(r)
if err != nil {
http.Error(w, "invalid Authorization: "+err.Error(), 400)
return
}
if auth.Tags.GetFirst([]string{"t", "upload"}) == nil {
http.Error(w, "invalid Authorization event \"t\" tag", 403)
return
}
b := make([]byte, 50, 1<<20 /* 1MB */)
// read first bytes of upload so we can find out the filetype
n, err := r.Body.Read(b)
if err != nil {
http.Error(w, "failed to read initial bytes of upload body: "+err.Error(), 400)
return
}
ft, _ := magic.Lookup(b)
if ft != nil {
ft.Extension = "." + ft.Extension
} else {
ft = &magic.FileType{
Extension: "",
}
}
// run the reject hooks
for _, ru := range bs.RejectUpload {
reject, reason := ru(r.Context(), auth, ft.Extension)
if reject {
http.Error(w, reason, 403)
return
}
}
// read the rest of the body
for {
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)
b = append(b, 0)[:len(b)]
}
}
if err != nil {
http.Error(w, "failed to read upload body: "+err.Error(), 400)
return
}
// store the authorization event, it serves as a way to keep track of the uploaded blobs
if err := bs.Store.SaveEvent(r.Context(), auth); err != nil {
http.Error(w, "failed to save event: "+err.Error(), 400)
return
}
// save blob
hash := sha256.Sum256(b)
hhash := hex.EncodeToString(hash[:])
for _, sb := range bs.StoreBlob {
if err := sb(r.Context(), hhash, b); err != nil {
http.Error(w, "failed to save: "+err.Error(), 500)
return
}
}
// return response
json.NewEncoder(w).Encode(Blob{
URL: bs.ServiceURL + "/" + hhash + ft.Extension,
SHA256: hhash,
Size: len(b),
Type: mime.TypeByExtension(ft.Extension),
Uploaded: nostr.Now(),
})
}
func (bs BlossomServer) handleGetBlob(w http.ResponseWriter, r *http.Request) {
spl := strings.SplitN(r.URL.Path, ".", 2)
hhash := spl[0]
if len(hhash) != 65 {
http.Error(w, "invalid /<sha256>[.ext] path", 400)
return
}
hhash = hhash[1:]
// check for an authorization tag, if any
auth, err := readAuthorization(r)
if err != nil && err != errMissingHeader {
http.Error(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 {
http.Error(w, "invalid Authorization event \"t\" tag", 403)
return
}
if auth.Tags.GetFirst([]string{"x", hhash}) == nil &&
auth.Tags.GetFirst([]string{"server", bs.ServiceURL}) == nil {
http.Error(w, "invalid Authorization event \"x\" or \"server\" tag", 403)
return
}
}
for _, rg := range bs.RejectGet {
reject, reason := rg(r.Context(), auth, hhash)
if reject {
http.Error(w, reason, 401)
return
}
}
var ext string
if len(spl) == 2 {
ext = "." + spl[1]
}
for _, lb := range bs.LoadBlob {
b, _ := lb(r.Context(), hhash)
if b != nil {
w.Header().Add("Content-Type", mime.TypeByExtension(ext))
w.Write(b)
return
}
}
http.Error(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 {
http.Error(w, "invalid /<sha256>[.ext] path", 400)
return
}
hhash = hhash[1:]
ch, err := bs.Store.QueryEvents(r.Context(), nostr.Filter{Tags: nostr.TagMap{"x": []string{hhash}}})
if err != nil {
http.Error(w, "failed to query: "+err.Error(), 500)
return
}
if <-ch == nil {
http.Error(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 && err != errMissingHeader {
http.Error(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 {
http.Error(w, "invalid Authorization event \"t\" tag", 403)
return
}
}
pubkey := r.URL.Path[6:]
for _, rl := range bs.RejectList {
reject, reason := rl(r.Context(), auth, pubkey)
if reject {
http.Error(w, reason, 401)
return
}
}
ch, err := bs.Store.QueryEvents(r.Context(), nostr.Filter{Authors: []string{pubkey}})
if err != nil {
http.Error(w, "failed to query: "+err.Error(), 500)
return
}
w.Write([]byte{'['})
enc := json.NewEncoder(w)
for evt := range ch {
hhashTag := evt.Tags.GetFirst([]string{"x", ""})
enc.Encode(Blob{
URL: bs.ServiceURL + "/" + (*hhashTag)[1],
SHA256: (*hhashTag)[1],
Uploaded: evt.CreatedAt,
})
}
w.Write([]byte{']'})
}
func (bs BlossomServer) handleDelete(w http.ResponseWriter, r *http.Request) {
}
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
View File

@ -0,0 +1,70 @@
package blossom
import (
"context"
"net/http"
"strings"
"github.com/fiatjaf/eventstore"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
type BlossomServer struct {
ServiceURL string
Store eventstore.Store
StoreBlob []func(ctx context.Context, sha256 string, body []byte) error
LoadBlob []func(ctx context.Context, sha256 string) ([]byte, error)
DeleteBlob []func(ctx context.Context, sha256 string) error
RejectUpload []func(ctx context.Context, auth *nostr.Event, ext string) (bool, string)
RejectGet []func(ctx context.Context, auth *nostr.Event, sha256 string) (bool, string)
RejectList []func(ctx context.Context, auth *nostr.Event, pubkey string) (bool, string)
}
func New(rl *khatru.Relay, serviceURL string, store eventstore.Store) *BlossomServer {
bs := &BlossomServer{
ServiceURL: serviceURL,
Store: store,
}
base := rl.Router()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/upload" && r.Method == "PUT" {
setCors(w)
bs.handleUpload(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/list/") && r.Method == "GET" {
setCors(w)
bs.handleList(w, r)
return
}
if len(strings.SplitN(r.URL.Path, ".", 2)[0]) == 65 {
if r.Method == "HEAD" {
setCors(w)
bs.handleHasBlob(w, r)
return
} else if r.Method == "GET" {
setCors(w)
bs.handleGetBlob(w, r)
return
} else if r.Method == "DELETE" {
setCors(w)
bs.handleDelete(w, r)
return
}
}
base.ServeHTTP(w, r)
})
rl.SetRouter(mux)
return bs
}

9
blossom/utils.go Normal file
View File

@ -0,0 +1,9 @@
package blossom
import "net/http"
func setCors(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET PUT DELETE")
}

38
examples/blossom/main.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"context"
"fmt"
"net/http"
"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", db)
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) ([]byte, error) {
fmt.Println("loading", sha256)
return []byte("aaaaa"), nil
})
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
}

View File

@ -15,6 +15,10 @@ func (rl *Relay) Router() *http.ServeMux {
return rl.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. // 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 { func (rl *Relay) Start(host string, port int, started ...chan bool) error {
addr := net.JoinHostPort(host, strconv.Itoa(port)) addr := net.JoinHostPort(host, strconv.Itoa(port))

1
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/bep/debounce v1.2.1 github.com/bep/debounce v1.2.1
github.com/fasthttp/websocket v1.5.7 github.com/fasthttp/websocket v1.5.7
github.com/fiatjaf/eventstore v0.12.0 github.com/fiatjaf/eventstore v0.12.0
github.com/liamg/magic v0.0.1
github.com/nbd-wtf/go-nostr v0.40.0 github.com/nbd-wtf/go-nostr v0.40.0
github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/rs/cors v1.7.0 github.com/rs/cors v1.7.0

4
go.sum
View File

@ -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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@ -235,5 +237,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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=