mirror of
https://github.com/fiatjaf/khatru.git
synced 2026-04-08 14:36:47 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15cd4e545 | ||
|
|
e6078b1a68 | ||
|
|
0ad33f78f1 | ||
|
|
c0848182c4 | ||
|
|
80586c4573 |
117
README.md
117
README.md
@@ -1 +1,116 @@
|
||||
khatru
|
||||
# khatru, a relay framework [](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay)
|
||||
|
||||
Khatru makes it easy to write very very custom relays:
|
||||
|
||||
- custom event or filter acceptance policies
|
||||
- custom `AUTH` handlers
|
||||
- custom storage and pluggable databases
|
||||
- custom webpages and other HTTP handlers
|
||||
|
||||
Here's a sample:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// create the relay instance
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
// set up some basic properties (will be returned on the NIP-11 endpoint)
|
||||
relay.Name = "my relay"
|
||||
relay.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
relay.Description = "this is my custom relay"
|
||||
relay.IconURL = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images"
|
||||
|
||||
// you must bring your own storage scheme -- if you want to have any
|
||||
store := make(map[string]*nostr.Event, 120)
|
||||
|
||||
// set up the basic relay functions
|
||||
relay.StoreEvent = append(relay.StoreEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
store[event.ID] = event
|
||||
return nil
|
||||
},
|
||||
)
|
||||
relay.QueryEvents = append(relay.QueryEvents,
|
||||
func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
|
||||
ch := make(chan *nostr.Event)
|
||||
go func() {
|
||||
for _, evt := range store {
|
||||
if filter.Matches(evt) {
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
},
|
||||
)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
delete(store, event.ID)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
// there are many other configurable things you can set
|
||||
relay.RejectEvent = append(relay.RejectEvent,
|
||||
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
|
||||
return true, "we don't allow this person to write here"
|
||||
}
|
||||
return false, "" // anyone else can
|
||||
},
|
||||
)
|
||||
relay.OnConnect = append(relay.OnConnect,
|
||||
func(ctx context.Context) {
|
||||
// request NIP-42 AUTH from everybody
|
||||
relay.RequestAuth(ctx)
|
||||
},
|
||||
)
|
||||
relay.OnAuth = append(relay.OnAuth,
|
||||
func(ctx context.Context, pubkey string) {
|
||||
// and when they auth we just log that for nothing
|
||||
log.Println(pubkey + " is authed!")
|
||||
},
|
||||
)
|
||||
// check the docs for more goodies!
|
||||
|
||||
mux := relay.Router()
|
||||
// set up other http handlers
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/html")
|
||||
fmt.Fprintf(w, `<b>welcome</b> to my relay!`)
|
||||
})
|
||||
|
||||
// start the server
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
}
|
||||
```
|
||||
|
||||
### But I don't want to write my own database!
|
||||
|
||||
Fear no more. Using the https://github.com/fiatjaf/eventstore module you get a bunch of compatible databases out of the box and you can just plug them into your relay. For example, [sqlite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3):
|
||||
|
||||
```go
|
||||
db := sqlite3.SQLite3Backend{DatabaseURL: "/tmp/khatru-sqlite-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)
|
||||
```
|
||||
|
||||
10
add-event.go
10
add-event.go
@@ -38,8 +38,7 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
previous := <-ch
|
||||
if previous != nil {
|
||||
if previous := <-ch; previous != nil && isOlder(previous, evt) {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
@@ -54,8 +53,7 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
previous := <-ch
|
||||
if previous != nil {
|
||||
if previous := <-ch; previous != nil && isOlder(previous, evt) {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
@@ -86,7 +84,11 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, ovw := range rl.OverwriteResponseEvent {
|
||||
ovw(ctx, evt)
|
||||
}
|
||||
notifyListeners(evt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
BIN
examples/readme-demo/demo-memory
Executable file
BIN
examples/readme-demo/demo-memory
Executable file
Binary file not shown.
87
examples/readme-demo/main.go
Normal file
87
examples/readme-demo/main.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// create the relay instance
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
// set up some basic properties (will be returned on the NIP-11 endpoint)
|
||||
relay.Name = "my relay"
|
||||
relay.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
relay.Description = "this is my custom relay"
|
||||
relay.IconURL = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images"
|
||||
|
||||
// you must bring your own storage scheme -- if you want to have any
|
||||
store := make(map[string]*nostr.Event, 120)
|
||||
|
||||
// set up the basic relay functions
|
||||
relay.StoreEvent = append(relay.StoreEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
store[event.ID] = event
|
||||
return nil
|
||||
},
|
||||
)
|
||||
relay.QueryEvents = append(relay.QueryEvents,
|
||||
func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
|
||||
ch := make(chan *nostr.Event)
|
||||
go func() {
|
||||
for _, evt := range store {
|
||||
if filter.Matches(evt) {
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
},
|
||||
)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
delete(store, event.ID)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
// there are many other configurable things you can set
|
||||
relay.RejectEvent = append(relay.RejectEvent,
|
||||
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
|
||||
return true, "we don't allow this person to write here"
|
||||
}
|
||||
return false, "" // anyone else can
|
||||
},
|
||||
)
|
||||
relay.OnConnect = append(relay.OnConnect,
|
||||
func(ctx context.Context) {
|
||||
// request NIP-42 AUTH from everybody
|
||||
relay.RequestAuth(ctx)
|
||||
},
|
||||
)
|
||||
relay.OnAuth = append(relay.OnAuth,
|
||||
func(ctx context.Context, pubkey string) {
|
||||
// and when they auth we just log that for nothing
|
||||
log.Println(pubkey + " is authed!")
|
||||
},
|
||||
)
|
||||
// check the docs for more goodies!
|
||||
|
||||
mux := relay.Router()
|
||||
// set up other http handlers
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/html")
|
||||
fmt.Fprintf(w, `<b>welcome</b> to my relay!`)
|
||||
})
|
||||
|
||||
// start the server
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/fasthttp/websocket v1.5.3
|
||||
github.com/fiatjaf/eventstore v0.1.0
|
||||
github.com/gobwas/ws v1.2.0
|
||||
github.com/nbd-wtf/go-nostr v0.25.0
|
||||
github.com/nbd-wtf/go-nostr v0.25.1
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1
|
||||
github.com/rs/cors v1.7.0
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
|
||||
|
||||
4
go.sum
4
go.sum
@@ -90,8 +90,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.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/nbd-wtf/go-nostr v0.25.0 h1:6ArnEX5NqjTaIBH6F5KYIJ0uw0uaKSWu8zjDb9za0Cg=
|
||||
github.com/nbd-wtf/go-nostr v0.25.0/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
|
||||
github.com/nbd-wtf/go-nostr v0.25.1 h1:YTLTDUgngfzd3qQ0fWmQmq20flwnGtHH0g0Q8S3HlW4=
|
||||
github.com/nbd-wtf/go-nostr v0.25.1/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
|
||||
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=
|
||||
|
||||
18
handlers.go
18
handlers.go
@@ -124,12 +124,10 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// check signature (requires the ID to be set)
|
||||
if ok, err := evt.CheckSignature(); err != nil {
|
||||
reason := "error: failed to verify signature"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to verify signature"})
|
||||
return
|
||||
} else if !ok {
|
||||
reason := "invalid: signature is invalid"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "invalid: signature is invalid"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,12 +137,12 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
err = rl.AddEvent(ctx, &evt)
|
||||
}
|
||||
var reason *string
|
||||
|
||||
var reason string
|
||||
if err == nil {
|
||||
ok = true
|
||||
} else {
|
||||
msg := err.Error()
|
||||
reason = &msg
|
||||
reason = err.Error()
|
||||
}
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: ok, Reason: reason})
|
||||
case "COUNT":
|
||||
@@ -236,6 +234,9 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go func(ch chan *nostr.Event) {
|
||||
for event := range ch {
|
||||
for _, ovw := range rl.OverwriteResponseEvent {
|
||||
ovw(ctx, event)
|
||||
}
|
||||
ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &id, Event: *event})
|
||||
}
|
||||
eose.Done()
|
||||
@@ -273,8 +274,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
ctx = context.WithValue(ctx, AUTH_CONTEXT_KEY, pubkey)
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: true})
|
||||
} else {
|
||||
reason := "error: failed to authenticate"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to authenticate"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,3 +56,21 @@ func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Even
|
||||
return true, "event kind not allowed"
|
||||
}
|
||||
}
|
||||
|
||||
func PreventTimestampsInThePast(thresholdSeconds nostr.Timestamp) func(context.Context, *nostr.Event) (bool, string) {
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if nostr.Now()-event.CreatedAt > thresholdSeconds {
|
||||
return true, "event too old"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func PreventTimestampsInTheFuture(thresholdSeconds nostr.Timestamp) func(context.Context, *nostr.Event) (bool, string) {
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if event.CreatedAt-nostr.Now() > thresholdSeconds {
|
||||
return true, "event too much in the future"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
1
relay.go
1
relay.go
@@ -45,6 +45,7 @@ type Relay struct {
|
||||
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
|
||||
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
|
||||
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
|
||||
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
|
||||
StoreEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
DeleteEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
|
||||
|
||||
11
utils.go
11
utils.go
@@ -5,6 +5,8 @@ import (
|
||||
"hash/maphash"
|
||||
"regexp"
|
||||
"unsafe"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,4 +28,11 @@ func GetAuthed(ctx context.Context) string {
|
||||
return authedPubkey.(string)
|
||||
}
|
||||
|
||||
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 { return uint64(uintptr(unsafe.Pointer(k))) }
|
||||
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 {
|
||||
return uint64(uintptr(unsafe.Pointer(k)))
|
||||
}
|
||||
|
||||
func isOlder(previous, next *nostr.Event) bool {
|
||||
return previous.CreatedAt < next.CreatedAt ||
|
||||
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user