Compare commits

..

1 Commits

10 changed files with 44 additions and 309 deletions

117
README.md
View File

@@ -1,116 +1 @@
# khatru, a relay framework [![docs badge](https://img.shields.io/badge/docs-reference-blue)](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)
```
khatru

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"github.com/fiatjaf/eventstore"
"github.com/nbd-wtf/go-nostr"
)
@@ -39,7 +38,8 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
if err != nil {
continue
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
previous := <-ch
if previous != nil {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
@@ -54,7 +54,8 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
if err != nil {
continue
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
previous := <-ch
if previous != nil {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
@@ -67,7 +68,7 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
for _, store := range rl.StoreEvent {
if saveErr := store(ctx, evt); saveErr != nil {
switch saveErr {
case eventstore.ErrDupEvent:
case ErrDupEvent:
return nil
default:
errmsg := saveErr.Error()

5
errors.go Normal file
View File

@@ -0,0 +1,5 @@
package khatru
import "fmt"
var ErrDupEvent = fmt.Errorf("duplicate: event already exists")

Binary file not shown.

View File

@@ -1,87 +0,0 @@
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)
}

View File

@@ -124,10 +124,12 @@ 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 {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to verify signature"})
reason := "error: failed to verify signature"
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
return
} else if !ok {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "invalid: signature is invalid"})
reason := "invalid: signature is invalid"
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
return
}
@@ -137,12 +139,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 {
reason = err.Error()
msg := err.Error()
reason = &msg
}
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: ok, Reason: reason})
case "COUNT":
@@ -168,12 +170,12 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
filter := filters[i]
// overwrite the filter (for example, to eliminate some kinds or tags that we know we don't support)
for _, ovw := range rl.OverwriteCountFilter {
ovw(ctx, &filter)
for _, reject := range rl.RejectFilter {
if rejecting, msg := reject(ctx, filter); rejecting {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
continue
}
}
// then check if we'll reject this filter
for _, reject := range rl.RejectCountFilter {
if rejecting, msg := reject(ctx, filter); rejecting {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
@@ -181,7 +183,6 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
}
// run the functions to count (generally it will be just one)
for _, count := range rl.CountEvents {
res, err := count(ctx, filter)
if err != nil {
@@ -216,25 +217,14 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
filter := filters[i]
// overwrite the filter (for example, to eliminate some kinds or
// that we know we don't support)
for _, ovw := range rl.OverwriteFilter {
ovw(ctx, &filter)
}
// then check if we'll reject this filter (we apply this after overwriting
// because we may, for example, remove some things from the incoming filters
// that we know we don't support, and then if the end result is an empty
// filter we can just reject it)
for _, reject := range rl.RejectFilter {
for _, reject := range rl.RejectCountFilter {
if rejecting, msg := reject(ctx, filter); rejecting {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
eose.Done()
continue
}
}
// run the functions to query events (generally just one,
// but we might be fetching stuff from multiple places)
eose.Add(len(rl.QueryEvents))
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
@@ -286,7 +276,8 @@ 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 {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to authenticate"})
reason := "error: failed to authenticate"
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
}
}
}

View File

@@ -56,21 +56,3 @@ 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, ""
}
}

View File

@@ -2,11 +2,26 @@ package plugins
import (
"context"
"fmt"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
func NoPrefixFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
for _, id := range filter.IDs {
if len(id) != 64 {
return true, fmt.Sprintf("filters can only contain full ids")
}
}
for _, pk := range filter.Authors {
if len(pk) != 64 {
return true, fmt.Sprintf("filters can only contain full pubkeys")
}
}
return false, ""
}
func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
items := len(filter.Tags) + len(filter.Kinds)
@@ -16,49 +31,3 @@ func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, ms
return false, ""
}
func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
c := len(filter.Kinds) + len(filter.IDs) + len(filter.Authors)
for _, tagItems := range filter.Tags {
c += len(tagItems)
}
if c == 0 {
return true, "can't handle empty filters"
}
return false, ""
}
func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if filter.Search != "" {
return true, "search is not supported"
}
return false, ""
}
func RemoveSearchQueries(ctx context.Context, filter *nostr.Filter) {
filter.Search = ""
}
func RemoveAllButKinds(kinds ...uint16) func(context.Context, *nostr.Filter) {
return func(ctx context.Context, filter *nostr.Filter) {
if n := len(filter.Kinds); n > 0 {
newKinds := make([]int, 0, n)
for i := 0; i < n; i++ {
if k := filter.Kinds[i]; slices.Contains(kinds, uint16(k)) {
newKinds = append(newKinds, k)
}
}
filter.Kinds = newKinds
}
}
}
func RemoveAllButTags(tagNames ...string) func(context.Context, *nostr.Filter) {
return func(ctx context.Context, filter *nostr.Filter) {
for tagName := range filter.Tags {
if !slices.Contains(tagNames, tagName) {
delete(filter.Tags, tagName)
}
}
}
}

View File

@@ -46,8 +46,6 @@ type Relay struct {
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)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)
OverwriteCountFilter []func(ctx context.Context, filter *nostr.Filter)
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)

View File

@@ -5,8 +5,6 @@ import (
"hash/maphash"
"regexp"
"unsafe"
"github.com/nbd-wtf/go-nostr"
)
const (
@@ -28,11 +26,4 @@ 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 isOlder(previous, next *nostr.Event) bool {
return previous.CreatedAt < next.CreatedAt ||
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
}
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 { return uint64(uintptr(unsafe.Pointer(k))) }