Compare commits

...

35 Commits

Author SHA1 Message Date
fiatjaf
cc891d06ed update go-nostr and make checkid faster. 2024-09-24 19:04:18 -03:00
fiatjaf
08be77b914 empty filters are not insane. 2024-09-24 19:01:57 -03:00
fiatjaf
e25400a61a actually we don't need all that fluff. 2024-09-16 17:39:54 -03:00
fiatjaf
635e91f360 policies: shoehorn an iterator in a xsync map Range(). 2024-09-16 15:59:08 -03:00
kehiy
eb92c2aac5 make check id more simple. 2024-09-14 10:21:47 -03:00
fiatjaf
18fc0dc1dd ensure AddEvent contexts get cleaned so store queries aren't left half-read.
similar to 1196fabb86
2024-09-12 22:33:57 -03:00
fiatjaf
6d827ac89a return blank IP and do not rate-limit when calling AddEvent(). 2024-09-11 23:02:01 -03:00
fiatjaf
b8bb58f77c sane defaults: increase initial connections limit burst. 2024-09-11 08:00:39 -03:00
fiatjaf
79eaedcffe remove useless boolean comparison. 2024-09-05 14:38:59 -03:00
fiatjaf
aa2ac552ea support deleting "a" references. 2024-08-28 09:30:27 -03:00
Barry Deen
4e2b921af9 fix typo in nip42 docs 2024-08-25 16:23:26 -03:00
fiatjaf
cf98cfc6d8 update xsync. 2024-08-24 07:17:07 -03:00
fiatjaf
62a3b9b7d9 docs: remove mention to bolt. 2024-08-24 07:16:52 -03:00
fiatjaf
e8637afa38 policies: timestamps policies take time.Duration 2024-08-20 10:40:29 -03:00
fiatjaf
e38036a5e6 add a sample to docs landing page. 2024-08-20 08:01:37 -03:00
fiatjaf
6a9dcdcbd4 add a fuzz test. 2024-08-14 09:30:02 -03:00
fiatjaf
5ae4d1194a this fix was stupidly missing yesterday. 2024-08-14 08:29:28 -03:00
fiatjaf
753a1191b9 add test for that. 2024-08-13 13:59:08 -03:00
fiatjaf
3e6d763a79 fix wrong matching on listener remove when router is used.
pablo's crash, see https://t.me/nip29_temp/670
2024-08-13 13:21:41 -03:00
fiatjaf
3b03f6700c convert the random listener tests into fuzzy tests. 2024-08-13 11:42:22 -03:00
fiatjaf
d7056f1515 tests that caught the previous two bugs. 2024-08-04 17:44:12 -03:00
fiatjaf
4e0971bafe fix removeListenerId() -- do not replace twice with the same last item when a client has repeated ids. 2024-08-04 17:43:25 -03:00
fiatjaf
07ea3a35ec removeClientAndListeners() -- stop confusing listeners when removing from the same ws. 2024-08-04 17:25:19 -03:00
fiatjaf
ef57f54a28 fix listeners convoluted magic and add tests for it. 2024-08-01 18:16:08 -03:00
fiatjaf
a103353254 fix development mess. 2024-08-01 12:41:27 -03:00
fiatjaf
5f0f9eec99 .PreventBroadcast slice of functions for controlling what events get broadcasted and to whom.
addresses https://t.me/nip29_temp/303
2024-07-31 10:34:53 -03:00
fiatjaf
a63dc829df routing. 2024-07-30 12:53:27 -03:00
Barry Deen
096890804f Add instructions to download the framework 2024-07-30 08:44:45 -03:00
fiatjaf
5a90326695 fix sentence in docs. 2024-07-23 16:49:11 -03:00
fiatjaf
214b92ba3b management: check 'u' tag on authorization event. 2024-07-16 13:30:14 -03:00
fiatjaf
b79f735e7a docs: link to go docs from home. 2024-07-15 13:44:46 -03:00
fiatjaf
28d7b83186 docs: fix cookbook link on home. 2024-07-15 13:36:26 -03:00
fiatjaf
82ab43075a docs: add source code. 2024-07-15 13:35:40 -03:00
fiatjaf
bb8b36df80 add docs. 2024-07-15 13:25:07 -03:00
fiatjaf
5c7121a3f6 update go-nostr to fix some bugs. 2024-07-12 13:47:25 -03:00
45 changed files with 2156 additions and 167 deletions

View File

@@ -11,6 +11,9 @@ import (
// AddEvent sends an event through then normal add pipeline, as if it was received from a websocket.
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if evt == nil {
return false, errors.New("error: event is nil")
}

View File

@@ -7,5 +7,5 @@ import (
// BroadcastEvent emits an event to all listeners whose filters' match, skipping all filters and actions
// it also doesn't attempt to store the event or trigger any reactions or callbacks
func (rl *Relay) BroadcastEvent(evt *nostr.Event) {
notifyListeners(evt)
rl.notifyListeners(evt)
}

View File

@@ -3,6 +3,8 @@ package khatru
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/nbd-wtf/go-nostr"
)
@@ -10,10 +12,35 @@ import (
func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) error {
// event deletion -- nip09
for _, tag := range evt.Tags {
if len(tag) >= 2 && tag[0] == "e" {
// first we fetch the event
if len(tag) >= 2 {
var f nostr.Filter
switch tag[0] {
case "e":
f = nostr.Filter{IDs: []string{tag[1]}}
case "a":
spl := strings.Split(tag[1], ":")
if len(spl) != 3 {
continue
}
kind, err := strconv.Atoi(spl[0])
if err != nil {
continue
}
author := spl[1]
identifier := spl[2]
f = nostr.Filter{
Kinds: []int{kind},
Authors: []string{author},
Tags: nostr.TagMap{"d": []string{identifier}},
Until: &evt.CreatedAt,
}
default:
continue
}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, nostr.Filter{IDs: []string{tag[1]}})
ch, err := query(ctx, f)
if err != nil {
continue
}
@@ -24,13 +51,14 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
// got the event, now check if the user can delete it
acceptDeletion := target.PubKey == evt.PubKey
var msg string
if acceptDeletion == false {
if !acceptDeletion {
msg = "you are not the author of this event"
}
// but if we have a function to overwrite this outcome, use that instead
for _, odo := range rl.OverwriteDeletionOutcome {
acceptDeletion, msg = odo(ctx, target, evt)
}
if acceptDeletion {
// delete it
for _, del := range rl.DeleteEvent {

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

9
docs/.prettierrc.yaml Normal file
View File

@@ -0,0 +1,9 @@
semi: false
arrowParens: avoid
insertPragma: false
printWidth: 80
proseWrap: preserve
singleQuote: true
trailingComma: none
useTabs: false
bracketSpacing: false

2
docs/.vitepress/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
cache
dist

21
docs/.vitepress/config.js Normal file
View File

@@ -0,0 +1,21 @@
export default {
lang: 'en-US',
title: 'khatru',
description: 'a framework for making Nostr relays',
themeConfig: {
logo: '/logo.png',
nav: [
{text: 'Home', link: '/'},
{text: 'Why', link: '/why'},
{text: 'Use Cases', link: '/use-cases'},
{text: 'Get Started', link: '/getting-started'},
{text: 'Cookbook', link: '/cookbook'},
{text: 'Source', link: 'https://github.com/fiatjaf/khatru'}
],
editLink: {
pattern: 'https://github.com/fiatjaf/khatru/edit/master/docs/:path'
}
},
head: [['link', {rel: 'icon', href: '/logo.png'}]],
cleanUrls: true
}

View File

@@ -0,0 +1,11 @@
<script setup>
import DefaultTheme from 'vitepress/theme'
const {Layout} = DefaultTheme
</script>
<template>
<Layout>
<template #layout-bottom>
<div class="khatru-layout-bottom">~</div>
</template>
</Layout>
</template>

View File

@@ -0,0 +1,24 @@
:root {
--vp-c-brand-1: #2eafab;
--vp-c-brand-2: #30373b;
--vp-c-brand-3: #3b6a3e;
--vp-button-brand-bg: #2eafab;
--vp-button-brand-hover-bg: #3b6a3e;
--vp-button-brand-active-bg: #30373b;
--vp-c-bg: #f2e6e2;
--vp-c-bg-soft: #f3f2f0;
}
.dark {
--vp-c-bg: #0a0a08;
--vp-c-bg-soft: #161a0e;
}
.khatru-layout-bottom {
margin: 2rem auto;
width: 200px;
text-align: center;
font-family: monospace;
font-size: 2rem;
}

View File

@@ -0,0 +1,8 @@
import DefaultTheme from 'vitepress/theme'
import NostrifyLayout from './Layout.vue'
import './custom.css'
export default {
extends: DefaultTheme,
Layout: NostrifyLayout
}

1
docs/config.js Symbolic link
View File

@@ -0,0 +1 @@
.vitepress/config.js

62
docs/cookbook/auth.md Normal file
View File

@@ -0,0 +1,62 @@
---
outline: deep
---
# NIP-42 `AUTH`
`khatru` supports [NIP-42](https://nips.nostr.com/42) out of the box. The functionality is exposed in the following ways.
## Sending arbitrary `AUTH` challenges
At any time you can send an `AUTH` message to a client that is making a request.
It makes sense to give the user the option to authenticate right after they establish a connection, for example, when you have a relay that works differently depending on whether the user is authenticated or not.
```go
relay := khatru.NewRelay()
relay.OnConnect = append(relay.OnConnect, func(ctx context.Context) {
khatru.RequestAuth(ctx)
})
```
This will send a NIP-42 `AUTH` challenge message to the client so it will have the option to authenticate itself whenever it wants to.
## Signaling to the client that a specific query requires an authenticated user
If on `RejectFilter` or `RejectEvent` you prefix the message with `auth-required: `, that will automatically send an `AUTH` message before a `CLOSED` or `OK` with that prefix, such that the client will immediately be able to know it must authenticate to proceed and will already have the challenge required for that, so they can immediately replay the request.
```go
relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) {
return true, "auth-required: this query requires you to be authenticated"
})
relay.RejectEvent = append(relay.RejectEvent, func(ctx context.Context, event *nostr.Event) (bool, string) {
return true, "auth-required: publishing this event requires authentication"
})
```
## Reading the auth status of a client
After a client is authenticated and opens a new subscription with `REQ` or sends a new event with `EVENT`, you'll be able to read the public key they're authenticated with.
```go
relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) {
authenticatedUser := khatru.GetAuthed(ctx)
})
```
## Telling an authenticated user they're still not allowed to do something
If the user is authenticated but still not allowed (because some specific filters or events are only accessible to some specific users) you can reply on `RejectFilter` or `RejectEvent` with a message prefixed with `"restricted: "` to make that clear to clients.
```go
relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) {
authenticatedUser := khatru.GetAuthed(ctx)
if slices.Contain(authorizedUsers, authenticatedUser) {
return false
} else {
return true, "restricted: you're not a member of the privileged group that can read that stuff"
}
})
```

View File

@@ -0,0 +1,64 @@
---
outline: deep
---
# Generating custom live events
Suppose you want to generate a new event every time a goal is scored on some soccer game and send that to all clients subscribed to a given game according to a tag `t`.
We'll assume you'll be polling some HTTP API that gives you the game's current score, and that in your `main` function you'll start the function that does the polling:
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
go startPollingGame(relay)
// other stuff here
}
type GameStatus struct {
TeamA int `json:"team_a"`
TeamB int `json:"team_b"`
}
func startPollingGame(relay *khatru.Relay) {
current := GameStatus{0, 0}
for {
newStatus, err := fetchGameStatus()
if err != nil {
continue
}
if newStatus.TeamA > current.TeamA {
// team A has scored a goal, here we generate an event
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 1,
Content: "team A has scored!",
Tags: nostr.Tags{{"t", "this-game"}}
}
evt.Sign(global.RelayPrivateKey)
// calling BroadcastEvent will send the event to everybody who has been listening for tag "t=[this-game]"
// there is no need to do any code to keep track of these clients or who is listening to what, khatru
// does that already in the background automatically
relay.BroadcastEvent(evt)
// just calling BroadcastEvent won't cause this event to be be stored,
// if for any reason you want to store these events you must call the store functions manually
for _, store := range relay.StoreEvent {
store(context.TODO(), evt)
}
}
if newStatus.TeamB > current.TeamB {
// same here, if team B has scored a goal
// ...
}
}
}
func fetchGameStatus() (GameStatus, error) {
// implementation of calling some external API goes here
}
```

View File

@@ -0,0 +1,88 @@
---
outline: deep
---
# Generating events on the fly from a non-Nostr data-source
Suppose you want to serve events with the weather data for periods in the past. All you have is a big CSV file with the data.
Then you get a query like `{"#g": ["d6nvp"], "since": 1664074800, "until": 1666666800, "kind": 10774}`, imagine for a while that kind `10774` means weather data.
First you do some geohashing calculation to discover that `d6nvp` corresponds to Willemstad, Curaçao, then you query your XML file for the Curaçao weather data for the given period -- from `2022-09-25` to `2022-10-25`, then you return the events corresponding to such query, signed on the fly:
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
relay.QueryEvents = append(relay.QueryEvents,
handleWeatherQuery,
)
// other stuff here
}
func handleWeatherQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
if filter.Kind != 10774 {
// this function only handles kind 10774, if the query is for something else we return
// a nil channel, which corresponds to no results
return nil, nil
}
file, err := os.Open("weatherdata.xml")
if err != nil {
return nil, fmt.Errorf("we have lost our file: %w", err)
}
// QueryEvents functions are expected to return a channel
ch := make(chan *nostr.Event)
// and they can do their query asynchronously, emitting events to the channel as they come
go func () {
defer file.Close()
// we're going to do this for each tag in the filter
gTags, _ := filter.Tags["g"]
for _, gTag := range gTags {
// translate geohash into city name
citName, err := geohashToCityName(gTag)
if err != nil {
continue
}
reader := csv.NewReader(file)
for {
record, err := reader.Read()
if err != nil {
return
}
// ensure we're only getting records for Willemstad
if cityName != record[0] {
continue
}
date, _ := time.Parse("2006-01-02", record[1])
ts := nostr.Timestamp(date.Unix())
if ts > filter.Since && ts < filter.Until {
// we found a record that matches the filter, so we make
// an event on the fly and return it
evt := nostr.Event{
CreatedAt: ts,
Kind: 10774,
Tags: nostr.Tags{
{"temperature", record[2]},
{"condition", record[3]},
}
}
evt.Sign(global.RelayPrivateKey)
ch <- evt
}
}
}
}()
return ch, nil
}
```
Beware, the code above is inefficient and the entire approach is not very smart, it's meant just as an example.

58
docs/cookbook/dynamic.md Normal file
View File

@@ -0,0 +1,58 @@
---
outline: deep
---
# Generating `khatru` relays dynamically and serving them from the same path
Suppose you want to expose a different relay interface depending on the subdomain that is accessed. I don't know, maybe you want to serve just events with pictures on `pictures.example.com` and just events with audio files on `audios.example.com`; maybe you want just events in English on `en.example.com` and just examples in Portuguese on `pt.example.com`, there are many possibilities.
You could achieve that with a scheme like the following
```go
var topLevelHost = "example.com"
var mainRelay = khatru.NewRelay() // we're omitting all the configuration steps for brevity
var subRelays = xsync.NewMapOf[string, *khatru.Relay]()
func main () {
handler := http.HandlerFunc(dynamicRelayHandler)
log.Printf("listening at http://0.0.0.0:8080")
http.ListenAndServe("0.0.0.0:8080", handler)
}
func dynamicRelayHandler(w http.ResponseWriter, r *http.Request) {
var relay *khatru.Relay
subdomain := r.Host[0 : len(topLevelHost)-len(topLevelHost)]
if subdomain == "" {
// no subdomain, use the main top-level relay
relay = mainRelay
} else {
// call on subdomain, so get a dynamic relay
subdomain = subdomain[0 : len(subdomain)-1] // remove dangling "."
// get a dynamic relay
relay, _ = subRelays.LoadOrCompute(subdomain, func () *khatru.Relay {
return makeNewRelay(subdomain)
})
}
relay.ServeHTTP(w, r)
}
func makeNewRelay (subdomain string) *khatru.Relay {
// somehow use the subdomain to generate a relay with specific configurations
relay := khatru.NewRelay()
switch subdomain {
case "pictures":
// relay configuration shenanigans go here
case "audios":
// relay configuration shenanigans go here
case "en":
// relay configuration shenanigans go here
case "pt":
// relay configuration shenanigans go here
}
return relay
}
```
In practice you could come up with a way that allows all these dynamic relays to share a common underlying datastore, but this is out of the scope of this example.

72
docs/cookbook/embed.md Normal file
View File

@@ -0,0 +1,72 @@
---
outline: deep
---
# Mixing a `khatru` relay with other HTTP handlers
If you already have a web server with all its HTML handlers or a JSON HTTP API or anything like that, something like:
```go
func main() {
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
mux.HandleFunc("/.well-known/nostr.json", handleNIP05)
mux.HandleFunc("/page/{page}", handlePage)
mux.HandleFunc("/", handleHomePage)
log.Printf("listening at http://0.0.0.0:8080")
http.ListenAndServe("0.0.0.0:8080", mux)
}
```
Then you can easily inject a relay or two there in alternative paths if you want:
```diff
mux := http.NewServeMux()
+ relay1 := khatru.NewRelay()
+ relay2 := khatru.NewRelay()
+ // and so on
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
mux.HandleFunc("/.well-known/nostr.json", handleNIP05)
mux.HandleFunc("/page/{page}", handlePage)
mux.HandleFunc("/", handleHomePage)
+ mux.Handle("/relay1", relay1)
+ mux.Handle("/relay2", relay2)
+ // and so forth
log.Printf("listening at http://0.0.0.0:8080")
```
Imagine each of these relay handlers is different, each can be using a different eventstore and have different policies for writing and reading.
## Exposing a relay interface at the root
If you want to expose your relay at the root path `/` that is also possible. You can just use it as the `mux` directly:
```go
func main() {
relay := khatru.NewRelay()
// ... -- relay configuration steps (omitted for brevity)
mux := relay.Router() // the relay comes with its own http.ServeMux inside
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
mux.HandleFunc("/.well-known/nostr.json", handleNIP05)
mux.HandleFunc("/page/{page}", handlePage)
mux.HandleFunc("/", handleHomePage)
log.Printf("listening at http://0.0.0.0:8080")
http.ListenAndServe("0.0.0.0:8080", mux)
}
```
Every [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) instance comes with its own ['http.ServeMux`](https://pkg.go.dev/net/http#ServeMux) inside. It ensures all requests are handled normally, but intercepts the requests that are pertinent to the relay operation, specifically the WebSocket requests, and the [NIP-11](https://nips.nostr.com/11) and the [NIP-86](https://nips.nostr.com/86) HTTP requests.
## Exposing multiple relays at the same path or at the root
That's also possible, as long as you have a way of differentiating each HTTP request that comes at the middleware level and associating it with a `khatru.Relay` instance in the background.
See [dynamic](dynamic) for an example that does that using the subdomain. [`countries`](https://git.fiatjaf.com/countries) does it using the requester country implied from its IP address.

101
docs/cookbook/eventstore.md Normal file
View File

@@ -0,0 +1,101 @@
---
outline: deep
---
# Using the `eventstore` library
The [`eventstore`](https://github.com/fiatjaf/eventstore) library has adapters that you can easily plug into `khatru`'s:
* `StoreEvent`
* `DeleteEvent`
* `QueryEvents`
* `CountEvents`
For all of them you start by instantiating a struct containing some basic options and a pointer (a file path for local databases, a connection string for remote databases) to the data. Then you call `.Init()` and if all is well you're ready to start storing, querying and deleting events, so you can pass the respective functions to their `khatru` counterparts. These eventstores also expose a `.Close()` function that must be called if you're going to stop using that store and keep your application open.
Here's an example with the [Badger](https://pkg.go.dev/github.com/fiatjaf/eventstore/badger) adapter, made for the [Badger](https://github.com/dgraph-io/badger) embedded key-value database:
```go
package main
import (
"fmt"
"net/http"
"github.com/fiatjaf/eventstore/badger"
"github.com/fiatjaf/khatru"
)
func main() {
relay := khatru.NewRelay()
db := badger.BadgerBackend{Path: "/tmp/khatru-badger-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)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
}
```
[LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) works the same way.
[SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3) also stores things locally so it only needs a `Path`.
[PostgreSQL](https://pkg.go.dev/github.com/fiatjaf/eventstore/postgresql) and [MySQL](https://pkg.go.dev/github.com/fiatjaf/eventstore/mysql) use remote connections to database servers, so they take a `DatabaseURL` parameter, but after that it's the same.
## Using two at a time
If you want to use two different adapters at the same time that's easy. Just add both to the corresponding slices:
```go
relay.StoreEvent = append(relay.StoreEvent, db1.SaveEvent, db2.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db1.QueryEvents, db2.SaveEvent)
```
But that will duplicate events on both and then return duplicated events on each query.
## Sharding
You can do a kind of sharding, for example, by storing some events in one store and others in another:
For example, maybe you want kind 1 events in `db1` and kind 30023 events in `db30023`:
```go
relay.StoreEvent = append(relay.StoreEvent, func (ctx context.Context, evt *nostr.Event) error {
switch evt.Kind {
case 1:
return db1.StoreEvent(ctx, evt)
case 30023:
return db30023.StoreEvent(ctx, evt)
default:
return nil
}
})
relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
for _, kind := range filter.Kinds {
switch kind {
case 1:
filter1 := filter
filter1.Kinds = []int{1}
return db1.QueryEvents(ctx, filter1)
case 30023:
filter30023 := filter
filter30023.Kinds = []int{30023}
return db30023.QueryEvents(ctx, filter30023)
default:
return nil, nil
}
}
})
```
## Search
See [search](search).

View File

@@ -0,0 +1,67 @@
---
outline: deep
---
## Querying events from Google Drive
Suppose you have a bunch of events stored in text files on Google Drive and you want to serve them as a relay. You could just store each event as a separate file and use the native Google Drive search to match the queries when serving requests. It would probably not be as fast as using local database, but it would work.
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
relay.StoreEvent = append(relay.StoreEvent, handleEvent)
relay.QueryEvents = append(relay.QueryEvents, handleQuery)
// other stuff here
}
func handleEvent(ctx context.Context, event *nostr.Event) error {
// store each event as a file on google drive
_, err := gdriveService.Files.Create(googledrive.CreateOptions{
Name: event.ID, // with the name set to their id
Body: event.String(), // the body as the full event JSON
})
return err
}
func handleQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
// QueryEvents functions are expected to return a channel
ch := make(chan *nostr.Event)
// and they can do their query asynchronously, emitting events to the channel as they come
go func () {
if len(filter.IDs) > 0 {
// if the query is for ids we can do a simpler name match
for _, id := range filter.IDS {
results, _ := gdriveService.Files.List(googledrive.ListOptions{
Q: fmt.Sprintf("name = '%s'", id)
})
if len(results) > 0 {
var evt nostr.Event
json.Unmarshal(results[0].Body, &evt)
ch <- evt
}
}
} else {
// otherwise we use the google-provided search and hope it will catch tags that are in the event body
for tagName, tagValues := range filter.Tags {
results, _ := gdriveService.Files.List(googledrive.ListOptions{
Q: fmt.Sprintf("fullText contains '%s'", tagValues)
})
for _, result := range results {
var evt nostr.Event
json.Unmarshal(results[0].Body, &evt)
if filter.Match(evt) {
ch <- evt
}
}
}
}
}()
return ch, nil
}
```
(Disclaimer: since I have no idea of how to properly use the Google Drive API this interface is entirely made up.)

11
docs/cookbook/index.md Normal file
View File

@@ -0,0 +1,11 @@
# Cookbook
- [Dealing with `AUTH` messages and authenticated users](auth)
- [Configuring the Relay Management API](management)
- [Using the eventstore library](eventstore)
- [Custom store: creating Nostr events on the fly from a non-Nostr source](custom-stores)
- [Custom store: reading from Google Drive](google-drive)
- [Live event generation](custom-live-events)
- [Embedding `khatru` inside other Go HTTP servers](embed)
- [Generating relays dynamically and serving them from the same path](dynamic)
- [Routing between multiple relays](routing)

View File

@@ -0,0 +1,85 @@
---
outline: deep
---
# Setting up the Relay Management API
[NIP-86](https://nips.nostr.com/86) specifies a set of RPC methods for managing the boring aspects of relays, such as whitelisting or banning users, banning individual events, banning IPs and so on.
All [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) instances expose a field `ManagementAPI` with a [`RelayManagementAPI`](https://pkg.go.dev/github.com/fiatjaf/khatru#RelayManagementAPI) instance inside, which can be used for creating handlers for each of the RPC methods.
There is also a generic `RejectAPICall` which is a slice of functions that will be called before any RPC method, if they exist and, if any of them returns true, the request will be rejected.
The most basic implementation of a `RejectAPICall` handler would be one that checks the public key of the caller with a hardcoded public key of the relay owner:
```go
var owner = "<my-own-pubkey>"
var allowedPubkeys = make([]string, 0, 10)
func main () {
relay := khatru.NewRelay()
relay.ManagementAPI.RejectAPICall = append(relay.ManagementAPI.RejectAPICall,
func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string) {
user := khatru.GetAuthed(ctx)
if user != owner {
return true, "go away, intruder"
}
return false, ""
}
)
relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey string, reason string) error {
allowedPubkeys = append(allowedPubkeys, pubkey)
return nil
}
relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey string, reason string) error {
idx := slices.Index(allowedPubkeys, pubkey)
if idx == -1 {
return fmt.Errorf("pubkey already not allowed")
}
allowedPubkeys = slices.Delete(allowedPubkeys, idx, idx+1)
}
}
```
You can also not provide any `RejectAPICall` handler and do the approval specifically on each RPC handler.
In the following example any current member can include any other pubkey, and anyone who was added before is able to remove any pubkey that was added afterwards (not a very good idea, but serves as an example).
```go
var allowedPubkeys = []string{"<my-own-pubkey>"}
func main () {
relay := khatru.NewRelay()
relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey string, reason string) error {
caller := khatru.GetAuthed(ctx)
if slices.Contains(allowedPubkeys, caller) {
allowedPubkeys = append(allowedPubkeys, pubkey)
return nil
}
return fmt.Errorf("you're not authorized")
}
relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey string, reason string) error {
caller := khatru.GetAuthed(ctx)
callerIdx := slices.Index(allowedPubkeys, caller)
if callerIdx == -1 {
return fmt.Errorf("you're not even allowed here")
}
targetIdx := slices.Index(allowedPubkeys, pubkey)
if targetIdx < callerIdx {
// target is a bigger OG than the caller, so it has bigger influence and can't be removed
return fmt.Errorf("you're less powerful than the pubkey you're trying to remove")
}
// allow deletion since the target came after the caller
allowedPubkeys = slices.Delete(allowedPubkeys, targetIdx, targetIdx+1)
return nil
}
}
```

63
docs/cookbook/routing.md Normal file
View File

@@ -0,0 +1,63 @@
---
outline: deep
---
# Routing
If you have one (or more) set of policies that have to be executed in sequence (for example, first you check for the presence of a tag, then later in the next policies you use that tag without checking) and they only apply to some class of events, but you still want your relay to deal with other classes of events that can lead to cumbersome sets of rules, always having to check if an event meets the requirements and so on. There is where routing can help you.
It also can be handy if you get a [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) from somewhere else, like a library such as [`relay29`](https://github.com/fiatjaf/relay29), and you want to combine it with other policies without some interfering with the others. As in the example below:
```go
sk := os.Getenv("RELAY_SECRET_KEY")
// a relay for NIP-29 groups
groupsStore := badger.BadgerBackend{}
groupsStore.Init()
groupsRelay, _ := khatru29.Init(relay29.Options{Domain: "example.com", DB: groupsStore, SecretKey: sk})
// ...
// a relay for everything else
publicStore := slicestore.SliceStore{}
publicStore.Init()
publicRelay := khatru.NewRelay()
publicRelay.StoreEvent = append(publicRelay.StoreEvent, publicStore.SaveEvent)
publicRelay.QueryEvents = append(publicRelay.QueryEvents, publicStore.QueryEvents)
publicRelay.CountEvents = append(publicRelay.CountEvents, publicStore.CountEvents)
publicRelay.DeleteEvent = append(publicRelay.DeleteEvent, publicStore.DeleteEvent)
// ...
// a higher-level relay that just routes between the two above
router := khatru.NewRouter()
// route requests and events to the groups relay
router.Route().
Req(func (filter nostr.Filter) bool {
_, hasHTag := filter.Tags["h"]
if hasHTag {
return true
}
return slices.Contains(filter.Kinds, func (k int) bool { return k == 39000 || k == 39001 || k == 39002 })
}).
Event(func (event *nostr.Event) bool {
switch {
case event.Kind <= 9021 && event.Kind >= 9000:
return true
case event.Kind <= 39010 && event.Kind >= 39000:
return true
case event.Kind <= 12 && event.Kind >= 9:
return true
case event.Tags.GetFirst([]string{"h", ""}) != nil:
return true
default:
return false
}
}).
Relay(groupsRelay)
// route requests and events to the other
router.Route().
Req(func (filter nostr.Filter) bool { return true }).
Event(func (event *nostr.Event) bool { return true }).
Relay(publicRelay)
```

51
docs/cookbook/search.md Normal file
View File

@@ -0,0 +1,51 @@
---
outline: deep
---
# Implementing NIP-50 `search` support
The [`nostr.Filter` type](https://pkg.go.dev/github.com/nbd-wtf/go-nostr#Filter) has a `Search` field, so you basically just has to handle that if it's present.
It can be tricky to implement fulltext search properly though, so some [eventstores](eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded).
If you have any of these you can just use them just like any other eventstore:
```go
func main () {
// other stuff here
normal := &lmdb.LMDBBackend{Path: "data"}
os.MkdirAll(normal.Path, 0755)
if err := normal.Init(); err != nil {
panic(err)
}
search := bluge.BlugeBackend{Path: "search", RawEventStore: normal}
if err := search.Init(); err != nil {
panic(err)
}
relay.StoreEvent = append(relay.StoreEvent, normal.SaveEvent, search.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, normal.QueryEvents, search.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, normal.DeleteEvent, search.DeleteEvent)
// other stuff here
}
```
Note that in this case we're using the [LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) adapter for normal queries and it explicitly rejects any filter that contains a `Search` field, while [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge) rejects any filter _without_ a `Search` value, which make them pair well together.
Other adapters, like [SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3), implement search functionality on their own, so if you don't want to use that you would have to have a middleware between, like:
```go
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent, search.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
if len(filter.Search) > 0 {
return search.QueryEvents(ctx, filter)
} else {
filterNoSearch := filter
filterNoSearch.Search = ""
return normal.QueryEvents(ctx, filterNoSearch)
}
})
```

View File

@@ -0,0 +1,78 @@
---
outline: deep
---
# Getting Started
Download the library:
```bash
go get github.com/fiatjaf/khatru
```
Include the library:
```go
import "github.com/fiatjaf/khatru"
```
Then in your `main()` function, instantiate a new `Relay`:
```go
relay := khatru.NewRelay()
```
Optionally, set up basic info about the relay that will be returned according to [NIP-11](https://nips.nostr.com/11):
```go
relay.Info.Name = "my relay"
relay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
relay.Info.Description = "this is my custom relay"
relay.Info.Icon = "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"
```
Now we must set up the basic functions for accepting events and answering queries. We could make our own querying engine from scratch, but we can also use [eventstore](https://github.com/fiatjaf/eventstore). In this example we'll use the SQLite adapter:
```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.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
```
These are lists of functions that will be called in order every time an `EVENT` is received, or a `REQ` query is received. You can add more than one handler there, you can have a function that reads from some other server, but just in some cases, you can do anything.
The next step is adding some protection, because maybe we don't want to allow _anyone_ to write to our relay. Maybe we want to only allow people that have a pubkey starting with `"a"`, `"b"` or `"c"`:
```go
relay.RejectEvent = append(relay.RejectEvent, func (ctx context.Context, event *nostr.Event) (reject bool, msg string) {
firstHexChar := event.PubKey[0:1]
if firstHexChar == "a" || firstHexChar == "b" || firstHexChar == "c" {
return false, "" // allow
}
return true, "you're not allowed in this shard"
})
```
We can also make use of some default policies that come bundled with Khatru:
```go
import "github.com/fiatjaf/khatru" // implied
relay.RejectEvent = append(relay.RejectEvent, policies.PreventLargeTags, policies.PreventTimestampsInThePast(time.Hour * 2), policies.PreventTimestampsInTheFuture(time.Minute * 30))
```
There are many other ways to customize the relay behavior. Take a look at the [`Relay` struct docs](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) for more, or see the [cookbook](/cookbook/).
The last step is actually running the server. Our relay is actually an `http.Handler`, so it can just be ran directly with `http.ListenAndServe()` from the standard library:
```go
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
```
And that's it.

59
docs/index.md Normal file
View File

@@ -0,0 +1,59 @@
---
layout: home
hero:
name: khatru
text: a framework for making Nostr relays
tagline: write your custom relay with code over configuration
actions:
- theme: brand
text: Get Started
link: /getting-started
- theme: alt
text: Cookbook
link: /cookbook/
features:
- title: It's a library
icon: 🐢
link: /getting-started
details: This is not an executable that you have to tweak with config files, it's a library that you import and use, so you just write code and it does exactly what you want.
- title: It's very very customizable
icon: 🎶
link: /cookbook/embed
details: Run arbitrary functions to reject events, reject filters, overwrite results of queries, perform actual queries, mix the relay stuff with other HTTP handlers or even run it inside an existing website.
- title: It plugs into event stores easily
icon: 📦
link: /cookbook/eventstore
details: khatru's companion, the `eventstore` library, provides all methods for storing and querying events efficiently from SQLite, LMDB, Postgres, Badger and others.
- title: It supports NIP-42 AUTH
icon: 🪪
link: /cookbook/auth
details: You can check if a client is authenticated or request AUTH anytime, or reject an event or a filter with an "auth-required:" and it will be handled automatically.
- title: It supports NIP-86 Management API
icon: 🛠️
link: /cookbook/management
details: You just define your custom handlers for each RPC call and they will be exposed appropriately to management clients.
- title: It's written in Go
icon: 🛵
link: https://pkg.go.dev/github.com/fiatjaf/khatru
details: That means it is fast and lightweight, you can learn the language in 5 minutes and it builds your relay into a single binary that's easy to ship and deploy.
---
## A glimpse of `khatru`'s power
It allows you to create a fully-functional relay in 7 lines of code:
```go
func main() {
relay := khatru.NewRelay()
db := badger.BadgerBackend{Path: "/tmp/khatru-badgern-tmp"}
db.Init()
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
http.ListenAndServe(":3334", relay)
}
```
After that you can customize it in infinite ways. See the links above.

7
docs/justfile Normal file
View File

@@ -0,0 +1,7 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
dev:
vitepress dev
build:
vitepress build

BIN
docs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

5
docs/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"vitepress": "^1.3.0"
}
}

26
docs/use-cases.md Normal file
View File

@@ -0,0 +1,26 @@
# Use cases
`khatru` is being used today in the real world by
* [pyramid](https://github.com/github-tijlxyz/khatru-pyramid), a relay with a invite-based whitelisting system similar to [lobste.rs](https://lobste.rs)
* [triflector](https://github.com/coracle-social/triflector), a relay which enforces authentication based on custom policy
* [countries](https://git.fiatjaf.com/countries), a relay that stores and serves content differently according to the country of the reader or writer
* [jingle](https://github.com/fiatjaf/jingle), a simple relay that exposes part of `khatru`'s configuration options to JavaScript code supplied by the user that is interpreted at runtime
* [njump](https://git.njump.me/njump), a Nostr gateway to the web that also serves its cached content in a relay interface
* [song](https://git.fiatjaf.com/song), a personal git server that comes with an embedded relay dedicated to dealing with [NIP-34](https://nips.nostr.com/34) git-related Nostr events
* [relay29](https://github.com/fiatjaf/relay29), a relay that powers most of the [NIP-29](https://nips.nostr.com/29) Nostr groups ecosystem
* [fiatjaf.com](https://fiatjaf.com), a personal website that serves the same content as HTML but also as Nostr events.
## Other possible use cases
Other possible use cases, still not developed, include:
* Bridges: `khatru` was initially developed to serve as an RSS-to-Nostr bridge server that would fetch RSS feeds on demand in order to serve them to Nostr clients. Other similar use cases could fit.
* Paid relays: Nostr has multiple relays that charge for write-access currently, but there are many other unexplored ways to make this scheme work: charge per each note, charge per month, charge per month per note, have different payment methods, and so on.
* Other whitelisting schemes: _pyramid_ implements a cool inviting scheme for granting access to the relay, same for _triflector_, but there are infinite other possibilities of other ways to grant access to people to an exclusive or community relay.
* Just-in-time content generation: instead of storing a bunch of signed JSON and serving that to clients, there could be relays that store data in a more compact format and turn it into Nostr events at the time they receive a request from a Nostr client -- or relays that do some kind of live data generation based on who is connected, not storing anything.
* Community relays: some internet communities may want relays that restrict writing or browsing of content only to its members, essentially making it a closed group -- or it could be closed for outsiders to write, but public for them to read and vice-versa.
* Automated moderation schemes: relays that are owned by a group (either a static or a dynamic group) can rely on signals from their members, like mutes or reports, to decide what content to allow in its domains and what to disallow, making crowdfunded moderation easy.
* Curation: in the same way as community relays can deal with unwanted content, they can also perform curation based on signals from their members (for example, if a member of the relay likes some note from someone that is outside the relay that note can be fetched and stored), creating a dynamic relay that can be browsed by anyone that share the same interests as that community.
* Local relays: a relay that can be only browsed by people using the WiFi connection of some event or some building, serving as a way to share temporary or restricted content that only interests people sharing that circumstance.
* Cool experiments: relays that only allow one note per user per day, relays that require proof-of-work on event ids], relays that require engagement otherwise you get kicked, relays that return events in different ordering, relays that impose arbitrary funny rules on notes in order for them to be accepted (i.e. they must contain the word "poo"), I don't know!

10
docs/why.md Normal file
View File

@@ -0,0 +1,10 @@
# Why `khatru`?
If you want to craft a relay that isn't completely dumb, but it's supposed to
* have custom own policies for accepting events;
* handle requests for stored events using data from multiple sources;
* require users to authenticate for some operations and not for others;
* and other stuff.
`khatru` provides a simple framework for creating your custom relay without having to reimplement it all from scratch or hack into other relay codebases.

70
examples/routing/main.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"fmt"
"net/http"
"slices"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/eventstore/sqlite3"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
func main() {
db1 := slicestore.SliceStore{}
db1.Init()
r1 := khatru.NewRelay()
r1.StoreEvent = append(r1.StoreEvent, db1.SaveEvent)
r1.QueryEvents = append(r1.QueryEvents, db1.QueryEvents)
r1.CountEvents = append(r1.CountEvents, db1.CountEvents)
r1.DeleteEvent = append(r1.DeleteEvent, db1.DeleteEvent)
db2 := sqlite3.SQLite3Backend{DatabaseURL: "/tmp/t"}
db2.Init()
r2 := khatru.NewRelay()
r2.StoreEvent = append(r2.StoreEvent, db2.SaveEvent)
r2.QueryEvents = append(r2.QueryEvents, db2.QueryEvents)
r2.CountEvents = append(r2.CountEvents, db2.CountEvents)
r2.DeleteEvent = append(r2.DeleteEvent, db2.DeleteEvent)
db3 := slicestore.SliceStore{}
db3.Init()
r3 := khatru.NewRelay()
r3.StoreEvent = append(r3.StoreEvent, db3.SaveEvent)
r3.QueryEvents = append(r3.QueryEvents, db3.QueryEvents)
r3.CountEvents = append(r3.CountEvents, db3.CountEvents)
r3.DeleteEvent = append(r3.DeleteEvent, db3.DeleteEvent)
router := khatru.NewRouter()
router.Route().
Req(func(filter nostr.Filter) bool {
return slices.Contains(filter.Kinds, 30023)
}).
Event(func(event *nostr.Event) bool {
return event.Kind == 30023
}).
Relay(r1)
router.Route().
Req(func(filter nostr.Filter) bool {
return slices.Contains(filter.Kinds, 1) && slices.Contains(filter.Tags["t"], "spam")
}).
Event(func(event *nostr.Event) bool {
return event.Kind == 1 && event.Tags.GetFirst([]string{"t", "spam"}) != nil
}).
Relay(r2)
router.Route().
Req(func(filter nostr.Filter) bool {
return slices.Contains(filter.Kinds, 1)
}).
Event(func(event *nostr.Event) bool {
return event.Kind == 1
}).
Relay(r3)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", router)
}

View File

@@ -49,11 +49,12 @@ func (rl *Relay) Start(host string, port int, started ...chan bool) error {
// Shutdown sends a websocket close control message to all connected clients.
func (rl *Relay) Shutdown(ctx context.Context) {
rl.httpServer.Shutdown(ctx)
rl.clients.Range(func(conn *websocket.Conn, _ struct{}) bool {
conn.WriteControl(websocket.CloseMessage, nil, time.Now().Add(time.Second))
conn.Close()
rl.clients.Delete(conn)
return true
})
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
for ws := range rl.clients {
ws.conn.WriteControl(websocket.CloseMessage, nil, time.Now().Add(time.Second))
ws.conn.Close()
}
clear(rl.clients)
rl.listeners = rl.listeners[:0]
}

32
go.mod
View File

@@ -1,24 +1,28 @@
module github.com/fiatjaf/khatru
go 1.21.4
go 1.23.0
toolchain go1.23.1
require (
github.com/fasthttp/websocket v1.5.7
github.com/fiatjaf/eventstore v0.5.0
github.com/nbd-wtf/go-nostr v0.34.2
github.com/puzpuzpuz/xsync/v3 v3.0.2
github.com/fiatjaf/eventstore v0.9.0
github.com/nbd-wtf/go-nostr v0.37.3
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/rs/cors v1.7.0
github.com/stretchr/testify v1.9.0
)
require (
github.com/PowerDNS/lmdb-go v1.9.2 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/aquasecurity/esquery v0.2.0 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -28,7 +32,7 @@ require (
github.com/fatih/structs v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.1.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -42,15 +46,17 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.25.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

60
go.sum
View File

@@ -6,10 +6,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aquasecurity/esquery v0.2.0 h1:9WWXve95TE8hbm3736WB7nS6Owl8UGDeu+0jiyE9ttA=
github.com/aquasecurity/esquery v0.2.0/go.mod h1:VU+CIFR6C+H142HHZf9RUkp4Eedpo9UrEKeCQHWf9ao=
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
@@ -19,10 +19,10 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs=
github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
@@ -47,8 +47,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.5.0 h1:s+oROGUylAJhntIAPLgLekpTtxpExNd+QhSw0tby7Es=
github.com/fiatjaf/eventstore v0.5.0/go.mod h1:A3SgQ8hwDjZuhZ1aFT250BA70EsWsTIw0KRjm6PDh0w=
github.com/fiatjaf/eventstore v0.9.0 h1:WsGDVAaRaVaV/J8PdrQDGfzChrL13q+lTO4C44rhu3E=
github.com/fiatjaf/eventstore v0.9.0/go.mod h1:JrAce5h0wi79+Sw4gsEq5kz0NtUxbVkOZ7lAo7ay6R8=
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=
@@ -56,8 +56,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -90,8 +90,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jgroeneveld/schema v1.0.0 h1:J0E10CrOkiSEsw6dfb1IfrDJD14pf6QLVJ3tRPl/syI=
github.com/jgroeneveld/schema v1.0.0/go.mod h1:M14lv7sNMtGvo3ops1MwslaSYgDYxrSmbzWIQ0Mr5rs=
@@ -105,6 +105,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
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/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=
@@ -113,15 +117,17 @@ 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.34.2 h1:9b4qZ29DhQf9xEWN8/7zfDD868r1jFbpjrR3c+BHc+E=
github.com/nbd-wtf/go-nostr v0.34.2/go.mod h1:NZQkxl96ggbO8rvDpVjcsojJqKTPwqhP4i82O7K5DJs=
github.com/nbd-wtf/go-nostr v0.37.3 h1:p/rrOWhaAk78UCVwzWtTN1C8WbP2k5eQV4HlpEYAeeA=
github.com/nbd-wtf/go-nostr v0.37.3/go.mod h1:TGKGj00BmJRXvRe0LlpDN3KKbELhhPXgBwUEhzu3Oq0=
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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew=
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
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/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
@@ -135,8 +141,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -154,8 +160,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -170,8 +176,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -184,8 +190,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -224,6 +230,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=

View File

@@ -3,7 +3,6 @@ package khatru
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
@@ -47,7 +46,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
rl.Log.Printf("failed to upgrade websocket: %v\n", err)
return
}
rl.clients.Store(conn, struct{}{})
ticker := time.NewTicker(rl.PingPeriod)
// NIP-42 challenge
@@ -60,6 +59,10 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
Challenge: hex.EncodeToString(challenge),
}
rl.clientsMutex.Lock()
rl.clients[ws] = make([]listenerSpec, 0, 2)
rl.clientsMutex.Unlock()
ctx, cancel := context.WithCancel(
context.WithValue(
context.Background(),
@@ -74,11 +77,9 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ticker.Stop()
cancel()
if _, ok := rl.clients.Load(conn); ok {
conn.Close()
rl.clients.Delete(conn)
removeListener(ws)
}
conn.Close()
rl.removeClientAndListeners(ws)
}
go func() {
@@ -126,9 +127,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
switch env := envelope.(type) {
case *nostr.EventEnvelope:
// check id
hash := sha256.Sum256(env.Event.Serialize())
id := hex.EncodeToString(hash[:])
if id != env.Event.ID {
if env.Event.CheckID() {
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: id is computed incorrectly"})
return
}
@@ -167,25 +166,31 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
}
srl := rl
if rl.getSubRelayFromEvent != nil {
srl = rl.getSubRelayFromEvent(&env.Event)
}
var ok bool
var writeErr error
var skipBroadcast bool
if env.Event.Kind == 5 {
// this always returns "blocked: " whenever it returns an error
writeErr = rl.handleDeleteRequest(ctx, &env.Event)
writeErr = srl.handleDeleteRequest(ctx, &env.Event)
} else {
// this will also always return a prefixed reason
skipBroadcast, writeErr = rl.AddEvent(ctx, &env.Event)
skipBroadcast, writeErr = srl.AddEvent(ctx, &env.Event)
}
var reason string
if writeErr == nil {
ok = true
for _, ovw := range rl.OverwriteResponseEvent {
for _, ovw := range srl.OverwriteResponseEvent {
ovw(ctx, &env.Event)
}
if !skipBroadcast {
notifyListeners(&env.Event)
srl.notifyListeners(&env.Event)
}
} else {
reason = writeErr.Error()
@@ -199,9 +204,14 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: "unsupported: this relay does not support NIP-45"})
return
}
var total int64
for _, filter := range env.Filters {
total += rl.handleCountRequest(ctx, ws, filter)
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(filter)
}
total += srl.handleCountRequest(ctx, ws, filter)
}
ws.WriteJSON(nostr.CountEnvelope{SubscriptionID: env.SubscriptionID, Count: &total})
case *nostr.ReqEnvelope:
@@ -216,7 +226,11 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
// handle each filter separately -- dispatching events as they're loaded from databases
for _, filter := range env.Filters {
err := rl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
srl := rl
if rl.getSubRelayFromFilter != nil {
srl = rl.getSubRelayFromFilter(filter)
}
err := srl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
if err != nil {
// fail everything if any filter is rejected
reason := err.Error()
@@ -226,6 +240,8 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: reason})
cancelReqCtx(errors.New("filter rejected"))
return
} else {
rl.addListener(ws, env.SubscriptionID, srl, filter, cancelReqCtx)
}
}
@@ -236,10 +252,9 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
cancelReqCtx(nil)
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
}()
setListener(env.SubscriptionID, ws, env.Filters, cancelReqCtx)
case *nostr.CloseEnvelope:
removeListenerId(ws, string(*env))
id := string(*env)
rl.removeListenerId(ws, id)
case *nostr.AuthEnvelope:
wsBaseUrl := strings.Replace(rl.ServiceURL, "http", "ws", 1)
if pubkey, ok := nip42.ValidateAuthEvent(&env.Event, ws.Challenge, wsBaseUrl); ok {

View File

@@ -2,86 +2,145 @@ package khatru
import (
"context"
"fmt"
"errors"
"slices"
"github.com/nbd-wtf/go-nostr"
"github.com/puzpuzpuz/xsync/v3"
)
type Listener struct {
filters nostr.Filters
cancel context.CancelCauseFunc
var ErrSubscriptionClosedByClient = errors.New("subscription closed by client")
type listenerSpec struct {
id string // kept here so we can easily match against it removeListenerId
cancel context.CancelCauseFunc
index int
subrelay *Relay // this is important when we're dealing with routing, otherwise it will be always the same
}
var listeners = xsync.NewMapOf[*WebSocket, *xsync.MapOf[string, *Listener]]()
type listener struct {
id string // duplicated here so we can easily send it on notifyListeners
filter nostr.Filter
ws *WebSocket
}
func GetListeningFilters() nostr.Filters {
respfilters := make(nostr.Filters, 0, listeners.Size()*2)
// here we go through all the existing listeners
listeners.Range(func(_ *WebSocket, subs *xsync.MapOf[string, *Listener]) bool {
subs.Range(func(_ string, listener *Listener) bool {
for _, listenerfilter := range listener.filters {
for _, respfilter := range respfilters {
// check if this filter specifically is already added to respfilters
if nostr.FilterEqual(listenerfilter, respfilter) {
goto nextconn
}
}
// field not yet present on respfilters, add it
respfilters = append(respfilters, listenerfilter)
// continue to the next filter
nextconn:
continue
}
return true
})
return true
})
// respfilters will be a slice with all the distinct filter we currently have active
func (rl *Relay) GetListeningFilters() []nostr.Filter {
respfilters := make([]nostr.Filter, len(rl.listeners))
for i, l := range rl.listeners {
respfilters[i] = l.filter
}
return respfilters
}
func setListener(id string, ws *WebSocket, filters nostr.Filters, cancel context.CancelCauseFunc) {
subs, _ := listeners.LoadOrCompute(ws, func() *xsync.MapOf[string, *Listener] {
return xsync.NewMapOf[string, *Listener]()
})
subs.Store(id, &Listener{filters: filters, cancel: cancel})
// addListener may be called multiple times for each id and ws -- in which case each filter will
// be added as an independent listener
func (rl *Relay) addListener(
ws *WebSocket,
id string,
subrelay *Relay,
filter nostr.Filter,
cancel context.CancelCauseFunc,
) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
if specs, ok := rl.clients[ws]; ok /* this will always be true unless client has disconnected very rapidly */ {
idx := len(subrelay.listeners)
rl.clients[ws] = append(specs, listenerSpec{
id: id,
cancel: cancel,
subrelay: subrelay,
index: idx,
})
subrelay.listeners = append(subrelay.listeners, listener{
ws: ws,
id: id,
filter: filter,
})
}
}
// remove a specific subscription id from listeners for a given ws client
// and cancel its specific context
func removeListenerId(ws *WebSocket, id string) {
if subs, ok := listeners.Load(ws); ok {
if listener, ok := subs.LoadAndDelete(id); ok {
listener.cancel(fmt.Errorf("subscription closed by client"))
}
if subs.Size() == 0 {
listeners.Delete(ws)
func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
if specs, ok := rl.clients[ws]; ok {
// swap delete specs that match this id
for s := len(specs) - 1; s >= 0; s-- {
spec := specs[s]
if spec.id == id {
spec.cancel(ErrSubscriptionClosedByClient)
specs[s] = specs[len(specs)-1]
specs = specs[0 : len(specs)-1]
rl.clients[ws] = specs
// swap delete listeners one at a time, as they may be each in a different subrelay
srl := spec.subrelay // == rl in normal cases, but different when this came from a route
if spec.index != len(srl.listeners)-1 {
movedFromIndex := len(srl.listeners) - 1
moved := srl.listeners[movedFromIndex] // this wasn't removed, but will be moved
srl.listeners[spec.index] = moved
// now we must update the the listener we just moved
// so its .index reflects its new position on srl.listeners
movedSpecs := rl.clients[moved.ws]
idx := slices.IndexFunc(movedSpecs, func(ls listenerSpec) bool {
return ls.index == movedFromIndex && ls.subrelay == srl
})
movedSpecs[idx].index = spec.index
rl.clients[moved.ws] = movedSpecs
}
srl.listeners = srl.listeners[0 : len(srl.listeners)-1] // finally reduce the slice length
}
}
}
}
// remove WebSocket conn from listeners
// (no need to cancel contexts as they are all inherited from the main connection context)
func removeListener(ws *WebSocket) {
listeners.Delete(ws)
func (rl *Relay) removeClientAndListeners(ws *WebSocket) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
if specs, ok := rl.clients[ws]; ok {
// swap delete listeners and delete client (all specs will be deleted)
for s, spec := range specs {
// no need to cancel contexts since they inherit from the main connection context
// just delete the listeners (swap-delete)
srl := spec.subrelay
if spec.index != len(srl.listeners)-1 {
movedFromIndex := len(srl.listeners) - 1
moved := srl.listeners[movedFromIndex] // this wasn't removed, but will be moved
srl.listeners[spec.index] = moved
// temporarily update the spec of the listener being removed to have index == -1
// (since it was removed) so it doesn't match in the search below
rl.clients[ws][s].index = -1
// now we must update the the listener we just moved
// so its .index reflects its new position on srl.listeners
movedSpecs := rl.clients[moved.ws]
idx := slices.IndexFunc(movedSpecs, func(ls listenerSpec) bool {
return ls.index == movedFromIndex && ls.subrelay == srl
})
movedSpecs[idx].index = spec.index
rl.clients[moved.ws] = movedSpecs
}
srl.listeners = srl.listeners[0 : len(srl.listeners)-1] // finally reduce the slice length
}
}
delete(rl.clients, ws)
}
func notifyListeners(event *nostr.Event) {
listeners.Range(func(ws *WebSocket, subs *xsync.MapOf[string, *Listener]) bool {
subs.Range(func(id string, listener *Listener) bool {
if !listener.filters.Match(event) {
return true
func (rl *Relay) notifyListeners(event *nostr.Event) {
for _, listener := range rl.listeners {
if listener.filter.Matches(event) {
for _, pb := range rl.PreventBroadcast {
if pb(listener.ws, event) {
return
}
}
ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &id, Event: *event})
return true
})
return true
})
listener.ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &listener.id, Event: *event})
}
}
}

188
listener_fuzz_test.go Normal file
View File

@@ -0,0 +1,188 @@
package khatru
import (
"math/rand"
"testing"
"github.com/nbd-wtf/go-nostr"
"github.com/stretchr/testify/require"
)
func FuzzRandomListenerClientRemoving(f *testing.F) {
f.Add(uint(20), uint(20), uint(1))
f.Fuzz(func(t *testing.T, utw uint, ubs uint, ualf uint) {
totalWebsockets := int(utw)
baseSubs := int(ubs)
addListenerFreq := int(ualf) + 1
rl := NewRelay()
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
websockets := make([]*WebSocket, 0, totalWebsockets*baseSubs)
l := 0
for i := 0; i < totalWebsockets; i++ {
ws := &WebSocket{}
websockets = append(websockets, ws)
rl.clients[ws] = nil
}
s := 0
for j := 0; j < baseSubs; j++ {
for i := 0; i < totalWebsockets; i++ {
ws := websockets[i]
w := idFromSeqUpper(i)
if s%addListenerFreq == 0 {
l++
rl.addListener(ws, w+":"+idFromSeqLower(j), rl, f, cancel)
}
s++
}
}
require.Len(t, rl.clients, totalWebsockets)
require.Len(t, rl.listeners, l)
for ws := range rl.clients {
rl.removeClientAndListeners(ws)
}
require.Len(t, rl.clients, 0)
require.Len(t, rl.listeners, 0)
})
}
func FuzzRandomListenerIdRemoving(f *testing.F) {
f.Add(uint(20), uint(20), uint(1), uint(4))
f.Fuzz(func(t *testing.T, utw uint, ubs uint, ualf uint, ualef uint) {
totalWebsockets := int(utw)
baseSubs := int(ubs)
addListenerFreq := int(ualf) + 1
addExtraListenerFreq := int(ualef) + 1
if totalWebsockets > 1024 || baseSubs > 1024 {
return
}
rl := NewRelay()
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
websockets := make([]*WebSocket, 0, totalWebsockets)
type wsid struct {
ws *WebSocket
id string
}
subs := make([]wsid, 0, totalWebsockets*baseSubs)
extra := 0
for i := 0; i < totalWebsockets; i++ {
ws := &WebSocket{}
websockets = append(websockets, ws)
rl.clients[ws] = nil
}
s := 0
for j := 0; j < baseSubs; j++ {
for i := 0; i < totalWebsockets; i++ {
ws := websockets[i]
w := idFromSeqUpper(i)
if s%addListenerFreq == 0 {
id := w + ":" + idFromSeqLower(j)
rl.addListener(ws, id, rl, f, cancel)
subs = append(subs, wsid{ws, id})
if s%addExtraListenerFreq == 0 {
rl.addListener(ws, id, rl, f, cancel)
extra++
}
}
s++
}
}
require.Len(t, rl.clients, totalWebsockets)
require.Len(t, rl.listeners, len(subs)+extra)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
require.Len(t, rl.listeners, 0)
require.Len(t, rl.clients, totalWebsockets)
for _, specs := range rl.clients {
require.Len(t, specs, 0)
}
})
}
func FuzzRouterListenersPabloCrash(f *testing.F) {
f.Add(uint(3), uint(6), uint(2), uint(20))
f.Fuzz(func(t *testing.T, totalRelays uint, totalConns uint, subFreq uint, subIterations uint) {
totalRelays++
totalConns++
subFreq++
subIterations++
rl := NewRelay()
relays := make([]*Relay, int(totalRelays))
for i := 0; i < int(totalRelays); i++ {
relays[i] = NewRelay()
}
conns := make([]*WebSocket, int(totalConns))
for i := 0; i < int(totalConns); i++ {
ws := &WebSocket{}
conns[i] = ws
rl.clients[ws] = make([]listenerSpec, 0, subIterations)
}
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
type wsid struct {
ws *WebSocket
id string
}
s := 0
subs := make([]wsid, 0, subIterations*totalConns*totalRelays)
for i, conn := range conns {
w := idFromSeqUpper(i)
for j := 0; j < int(subIterations); j++ {
id := w + ":" + idFromSeqLower(j)
for _, rlt := range relays {
if s%int(subFreq) == 0 {
rl.addListener(conn, id, rlt, f, cancel)
subs = append(subs, wsid{conn, id})
}
s++
}
}
}
for _, wsid := range subs {
rl.removeListenerId(wsid.ws, wsid.id)
}
for _, wsid := range subs {
require.Len(t, rl.clients[wsid.ws], 0)
}
for _, rlt := range relays {
require.Len(t, rlt.listeners, 0)
}
})
}

545
listener_test.go Normal file
View File

@@ -0,0 +1,545 @@
package khatru
import (
"math/rand"
"strings"
"testing"
"github.com/nbd-wtf/go-nostr"
"github.com/stretchr/testify/require"
)
func idFromSeqUpper(seq int) string { return idFromSeq(seq, 65, 90) }
func idFromSeqLower(seq int) string { return idFromSeq(seq, 97, 122) }
func idFromSeq(seq int, min, max int) string {
maxSeq := max - min + 1
nLetters := seq/maxSeq + 1
result := strings.Builder{}
result.Grow(nLetters)
for l := 0; l < nLetters; l++ {
letter := rune(seq%maxSeq + min)
result.WriteRune(letter)
}
return result.String()
}
func TestListenerSetupAndRemoveOnce(t *testing.T) {
rl := NewRelay()
ws1 := &WebSocket{}
ws2 := &WebSocket{}
f1 := nostr.Filter{Kinds: []int{1}}
f2 := nostr.Filter{Kinds: []int{2}}
f3 := nostr.Filter{Kinds: []int{3}}
rl.clients[ws1] = nil
rl.clients[ws2] = nil
var cancel func(cause error) = nil
t.Run("adding listeners", func(t *testing.T) {
rl.addListener(ws1, "1a", rl, f1, cancel)
rl.addListener(ws1, "1b", rl, f2, cancel)
rl.addListener(ws2, "2a", rl, f3, cancel)
rl.addListener(ws1, "1c", rl, f3, cancel)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"1a", cancel, 0, rl},
{"1b", cancel, 1, rl},
{"1c", cancel, 3, rl},
},
ws2: {
{"2a", cancel, 2, rl},
},
}, rl.clients)
require.Equal(t, []listener{
{"1a", f1, ws1},
{"1b", f2, ws1},
{"2a", f3, ws2},
{"1c", f3, ws1},
}, rl.listeners)
})
t.Run("removing a client", func(t *testing.T) {
rl.removeClientAndListeners(ws1)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws2: {
{"2a", cancel, 0, rl},
},
}, rl.clients)
require.Equal(t, []listener{
{"2a", f3, ws2},
}, rl.listeners)
})
}
func TestListenerMoreConvolutedCase(t *testing.T) {
rl := NewRelay()
ws1 := &WebSocket{}
ws2 := &WebSocket{}
ws3 := &WebSocket{}
ws4 := &WebSocket{}
f1 := nostr.Filter{Kinds: []int{1}}
f2 := nostr.Filter{Kinds: []int{2}}
f3 := nostr.Filter{Kinds: []int{3}}
rl.clients[ws1] = nil
rl.clients[ws2] = nil
rl.clients[ws3] = nil
rl.clients[ws4] = nil
var cancel func(cause error) = nil
t.Run("adding listeners", func(t *testing.T) {
rl.addListener(ws1, "c", rl, f1, cancel)
rl.addListener(ws2, "b", rl, f2, cancel)
rl.addListener(ws3, "a", rl, f3, cancel)
rl.addListener(ws4, "d", rl, f3, cancel)
rl.addListener(ws2, "b", rl, f1, cancel)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rl},
},
ws2: {
{"b", cancel, 1, rl},
{"b", cancel, 4, rl},
},
ws3: {
{"a", cancel, 2, rl},
},
ws4: {
{"d", cancel, 3, rl},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"b", f2, ws2},
{"a", f3, ws3},
{"d", f3, ws4},
{"b", f1, ws2},
}, rl.listeners)
})
t.Run("removing a client", func(t *testing.T) {
rl.removeClientAndListeners(ws2)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rl},
},
ws3: {
{"a", cancel, 2, rl},
},
ws4: {
{"d", cancel, 1, rl},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"d", f3, ws4},
{"a", f3, ws3},
}, rl.listeners)
})
t.Run("reorganize the first case differently and then remove again", func(t *testing.T) {
rl.clients = map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 1, rl},
},
ws2: {
{"b", cancel, 2, rl},
{"b", cancel, 4, rl},
},
ws3: {
{"a", cancel, 0, rl},
},
ws4: {
{"d", cancel, 3, rl},
},
}
rl.listeners = []listener{
{"a", f3, ws3},
{"c", f1, ws1},
{"b", f2, ws2},
{"d", f3, ws4},
{"b", f1, ws2},
}
rl.removeClientAndListeners(ws2)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 1, rl},
},
ws3: {
{"a", cancel, 0, rl},
},
ws4: {
{"d", cancel, 2, rl},
},
}, rl.clients)
require.Equal(t, []listener{
{"a", f3, ws3},
{"c", f1, ws1},
{"d", f3, ws4},
}, rl.listeners)
})
}
func TestListenerMoreStuffWithMultipleRelays(t *testing.T) {
rl := NewRelay()
ws1 := &WebSocket{}
ws2 := &WebSocket{}
ws3 := &WebSocket{}
ws4 := &WebSocket{}
f1 := nostr.Filter{Kinds: []int{1}}
f2 := nostr.Filter{Kinds: []int{2}}
f3 := nostr.Filter{Kinds: []int{3}}
rlx := NewRelay()
rly := NewRelay()
rlz := NewRelay()
rl.clients[ws1] = nil
rl.clients[ws2] = nil
rl.clients[ws3] = nil
rl.clients[ws4] = nil
var cancel func(cause error) = nil
t.Run("adding listeners", func(t *testing.T) {
rl.addListener(ws1, "c", rlx, f1, cancel)
rl.addListener(ws2, "b", rly, f2, cancel)
rl.addListener(ws3, "a", rlz, f3, cancel)
rl.addListener(ws4, "d", rlx, f3, cancel)
rl.addListener(ws4, "e", rlx, f3, cancel)
rl.addListener(ws3, "a", rlx, f3, cancel)
rl.addListener(ws4, "e", rly, f3, cancel)
rl.addListener(ws3, "f", rly, f3, cancel)
rl.addListener(ws1, "g", rlz, f1, cancel)
rl.addListener(ws2, "g", rlz, f2, cancel)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rlx},
{"g", cancel, 1, rlz},
},
ws2: {
{"b", cancel, 0, rly},
{"g", cancel, 2, rlz},
},
ws3: {
{"a", cancel, 0, rlz},
{"a", cancel, 3, rlx},
{"f", cancel, 2, rly},
},
ws4: {
{"d", cancel, 1, rlx},
{"e", cancel, 2, rlx},
{"e", cancel, 1, rly},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"d", f3, ws4},
{"e", f3, ws4},
{"a", f3, ws3},
}, rlx.listeners)
require.Equal(t, []listener{
{"b", f2, ws2},
{"e", f3, ws4},
{"f", f3, ws3},
}, rly.listeners)
require.Equal(t, []listener{
{"a", f3, ws3},
{"g", f1, ws1},
{"g", f2, ws2},
}, rlz.listeners)
})
t.Run("removing a subscription id", func(t *testing.T) {
// removing 'd' from ws4
rl.clients[ws4][0].cancel = func(cause error) {} // set since removing will call it
rl.removeListenerId(ws4, "d")
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rlx},
{"g", cancel, 1, rlz},
},
ws2: {
{"b", cancel, 0, rly},
{"g", cancel, 2, rlz},
},
ws3: {
{"a", cancel, 0, rlz},
{"a", cancel, 1, rlx},
{"f", cancel, 2, rly},
},
ws4: {
{"e", cancel, 1, rly},
{"e", cancel, 2, rlx},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"a", f3, ws3},
{"e", f3, ws4},
}, rlx.listeners)
require.Equal(t, []listener{
{"b", f2, ws2},
{"e", f3, ws4},
{"f", f3, ws3},
}, rly.listeners)
require.Equal(t, []listener{
{"a", f3, ws3},
{"g", f1, ws1},
{"g", f2, ws2},
}, rlz.listeners)
})
t.Run("removing another subscription id", func(t *testing.T) {
// removing 'a' from ws3
rl.clients[ws3][0].cancel = func(cause error) {} // set since removing will call it
rl.clients[ws3][1].cancel = func(cause error) {} // set since removing will call it
rl.removeListenerId(ws3, "a")
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rlx},
{"g", cancel, 1, rlz},
},
ws2: {
{"b", cancel, 0, rly},
{"g", cancel, 0, rlz},
},
ws3: {
{"f", cancel, 2, rly},
},
ws4: {
{"e", cancel, 1, rly},
{"e", cancel, 1, rlx},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"e", f3, ws4},
}, rlx.listeners)
require.Equal(t, []listener{
{"b", f2, ws2},
{"e", f3, ws4},
{"f", f3, ws3},
}, rly.listeners)
require.Equal(t, []listener{
{"g", f2, ws2},
{"g", f1, ws1},
}, rlz.listeners)
})
t.Run("removing a connection", func(t *testing.T) {
rl.removeClientAndListeners(ws2)
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rlx},
{"g", cancel, 0, rlz},
},
ws3: {
{"f", cancel, 0, rly},
},
ws4: {
{"e", cancel, 1, rly},
{"e", cancel, 1, rlx},
},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
{"e", f3, ws4},
}, rlx.listeners)
require.Equal(t, []listener{
{"f", f3, ws3},
{"e", f3, ws4},
}, rly.listeners)
require.Equal(t, []listener{
{"g", f1, ws1},
}, rlz.listeners)
})
t.Run("removing another subscription id", func(t *testing.T) {
// removing 'e' from ws4
rl.clients[ws4][0].cancel = func(cause error) {} // set since removing will call it
rl.clients[ws4][1].cancel = func(cause error) {} // set since removing will call it
rl.removeListenerId(ws4, "e")
require.Equal(t, map[*WebSocket][]listenerSpec{
ws1: {
{"c", cancel, 0, rlx},
{"g", cancel, 0, rlz},
},
ws3: {
{"f", cancel, 0, rly},
},
ws4: {},
}, rl.clients)
require.Equal(t, []listener{
{"c", f1, ws1},
}, rlx.listeners)
require.Equal(t, []listener{
{"f", f3, ws3},
}, rly.listeners)
require.Equal(t, []listener{
{"g", f1, ws1},
}, rlz.listeners)
})
}
func TestRandomListenerClientRemoving(t *testing.T) {
rl := NewRelay()
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
websockets := make([]*WebSocket, 0, 20)
l := 0
for i := 0; i < 20; i++ {
ws := &WebSocket{}
websockets = append(websockets, ws)
rl.clients[ws] = nil
}
for j := 0; j < 20; j++ {
for i := 0; i < 20; i++ {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
l++
rl.addListener(ws, w+":"+idFromSeqLower(j), rl, f, cancel)
}
}
}
require.Len(t, rl.clients, 20)
require.Len(t, rl.listeners, l)
for ws := range rl.clients {
rl.removeClientAndListeners(ws)
}
require.Len(t, rl.clients, 0)
require.Len(t, rl.listeners, 0)
}
func TestRandomListenerIdRemoving(t *testing.T) {
rl := NewRelay()
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
websockets := make([]*WebSocket, 0, 20)
type wsid struct {
ws *WebSocket
id string
}
subs := make([]wsid, 0, 20*20)
extra := 0
for i := 0; i < 20; i++ {
ws := &WebSocket{}
websockets = append(websockets, ws)
rl.clients[ws] = nil
}
for j := 0; j < 20; j++ {
for i := 0; i < 20; i++ {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
id := w + ":" + idFromSeqLower(j)
rl.addListener(ws, id, rl, f, cancel)
subs = append(subs, wsid{ws, id})
if rand.Intn(5) < 1 {
rl.addListener(ws, id, rl, f, cancel)
extra++
}
}
}
}
require.Len(t, rl.clients, 20)
require.Len(t, rl.listeners, len(subs)+extra)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
require.Len(t, rl.listeners, 0)
require.Len(t, rl.clients, 20)
for _, specs := range rl.clients {
require.Len(t, specs, 0)
}
}
func TestRouterListenersPabloCrash(t *testing.T) {
rl := NewRelay()
rla := NewRelay()
rlb := NewRelay()
ws1 := &WebSocket{}
ws2 := &WebSocket{}
ws3 := &WebSocket{}
rl.clients[ws1] = nil
rl.clients[ws2] = nil
rl.clients[ws3] = nil
f := nostr.Filter{Kinds: []int{1}}
cancel := func(cause error) {}
rl.addListener(ws1, ":1", rla, f, cancel)
rl.addListener(ws2, ":1", rlb, f, cancel)
rl.addListener(ws3, "a", rlb, f, cancel)
rl.addListener(ws3, "b", rla, f, cancel)
rl.addListener(ws3, "c", rlb, f, cancel)
rl.removeClientAndListeners(ws1)
rl.removeClientAndListeners(ws3)
}

View File

@@ -74,6 +74,9 @@ func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
} else if ok, _ := evt.CheckSignature(); !ok {
resp.Error = "invalid auth event"
goto respond
} else if uTag := evt.Tags.GetFirst([]string{"u", ""}); uTag == nil || getServiceBaseURL(r) != (*uTag)[1] {
resp.Error = "invalid 'u' tag"
goto respond
} else if pht := evt.Tags.GetFirst([]string{"payload", hex.EncodeToString(payloadHash[:])}); pht == nil {
resp.Error = "invalid auth event payload hash"
goto respond

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"github.com/nbd-wtf/go-nostr"
)
@@ -79,7 +80,8 @@ func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Even
}
}
func PreventTimestampsInThePast(thresholdSeconds nostr.Timestamp) func(context.Context, *nostr.Event) (bool, string) {
func PreventTimestampsInThePast(threshold time.Duration) func(context.Context, *nostr.Event) (bool, string) {
thresholdSeconds := nostr.Timestamp(threshold.Seconds())
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if nostr.Now()-event.CreatedAt > thresholdSeconds {
return true, "event too old"
@@ -88,7 +90,8 @@ func PreventTimestampsInThePast(thresholdSeconds nostr.Timestamp) func(context.C
}
}
func PreventTimestampsInTheFuture(thresholdSeconds nostr.Timestamp) func(context.Context, *nostr.Event) (bool, string) {
func PreventTimestampsInTheFuture(threshold time.Duration) func(context.Context, *nostr.Event) (bool, string) {
thresholdSeconds := nostr.Timestamp(threshold.Seconds())
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"

View File

@@ -18,13 +18,12 @@ func startRateLimitSystem[K comparable](
go func() {
for {
time.Sleep(interval)
negativeBuckets.Range(func(key K, bucket *atomic.Int32) bool {
for key, bucket := range negativeBuckets.Range {
newv := bucket.Add(int32(-tokensPerInterval))
if newv <= 0 {
negativeBuckets.Delete(key)
}
return true
})
}
}
}()

View File

@@ -13,7 +13,11 @@ func EventIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
return rl(khatru.GetIP(ctx)), "rate-limited: slow down, please"
ip := khatru.GetIP(ctx)
if ip == "" {
return false, ""
}
return rl(ip), "rate-limited: slow down, please"
}
}

View File

@@ -13,12 +13,11 @@ func ApplySaneDefaults(relay *khatru.Relay) {
)
relay.RejectFilter = append(relay.RejectFilter,
NoEmptyFilters,
NoComplexFilters,
FilterIPRateLimiter(20, time.Minute, 100),
)
relay.RejectConnection = append(relay.RejectConnection,
ConnectionRateLimiter(1, time.Minute*5, 3),
ConnectionRateLimiter(1, time.Minute*5, 10),
)
}

View File

@@ -5,16 +5,16 @@ import (
"log"
"net/http"
"os"
"sync"
"time"
"github.com/fasthttp/websocket"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/puzpuzpuz/xsync/v3"
)
func NewRelay() *Relay {
return &Relay{
rl := &Relay{
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
Info: &nip11.RelayInformationDocument{
@@ -29,7 +29,9 @@ func NewRelay() *Relay {
CheckOrigin: func(r *http.Request) bool { return true },
},
clients: xsync.NewMapOf[*websocket.Conn, struct{}](),
clients: make(map[*WebSocket][]listenerSpec, 100),
listeners: make([]listener, 0, 100),
serveMux: &http.ServeMux{},
WriteWait: 10 * time.Second,
@@ -37,28 +39,37 @@ func NewRelay() *Relay {
PingPeriod: 30 * time.Second,
MaxMessageSize: 512000,
}
return rl
}
type Relay struct {
ServiceURL string
// these structs keeps track of all the things that can be customized when handling events or requests
RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string)
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectConnection []func(r *http.Request) bool
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)
OverwriteRelayInformation []func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
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)
CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error)
OnConnect []func(ctx context.Context)
OnDisconnect []func(ctx context.Context)
OnEventSaved []func(ctx context.Context, event *nostr.Event)
OnEphemeralEvent []func(ctx context.Context, event *nostr.Event)
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)
OverwriteCountFilter []func(ctx context.Context, filter *nostr.Filter)
QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error)
RejectConnection []func(r *http.Request) bool
OnConnect []func(ctx context.Context)
OnDisconnect []func(ctx context.Context)
OverwriteRelayInformation []func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
PreventBroadcast []func(ws *WebSocket, event *nostr.Event) bool
// these are used when this relays acts as a router
routes []Route
getSubRelayFromEvent func(*nostr.Event) *Relay // used for handling EVENTs
getSubRelayFromFilter func(nostr.Filter) *Relay // used for handling REQs
// setting up handlers here will enable these methods
ManagementAPI RelayManagementAPI
@@ -74,7 +85,10 @@ type Relay struct {
upgrader websocket.Upgrader
// keep a connection reference to all connected clients for Server.Shutdown
clients *xsync.MapOf[*websocket.Conn, struct{}]
// also used for keeping track of who is listening to what
clients map[*WebSocket][]listenerSpec
listeners []listener
clientsMutex sync.Mutex
// in case you call Server.Start
Addr string

67
router.go Normal file
View File

@@ -0,0 +1,67 @@
package khatru
import (
"github.com/nbd-wtf/go-nostr"
)
type Router struct{ *Relay }
type Route struct {
eventMatcher func(*nostr.Event) bool
filterMatcher func(nostr.Filter) bool
relay *Relay
}
type routeBuilder struct {
router *Router
eventMatcher func(*nostr.Event) bool
filterMatcher func(nostr.Filter) bool
}
func NewRouter() *Router {
rr := &Router{Relay: NewRelay()}
rr.routes = make([]Route, 0, 3)
rr.getSubRelayFromFilter = func(f nostr.Filter) *Relay {
for _, route := range rr.routes {
if route.filterMatcher(f) {
return route.relay
}
}
return rr.Relay
}
rr.getSubRelayFromEvent = func(e *nostr.Event) *Relay {
for _, route := range rr.routes {
if route.eventMatcher(e) {
return route.relay
}
}
return rr.Relay
}
return rr
}
func (rr *Router) Route() routeBuilder {
return routeBuilder{
router: rr,
filterMatcher: func(f nostr.Filter) bool { return false },
eventMatcher: func(e *nostr.Event) bool { return false },
}
}
func (rb routeBuilder) Req(fn func(nostr.Filter) bool) routeBuilder {
rb.filterMatcher = fn
return rb
}
func (rb routeBuilder) Event(fn func(*nostr.Event) bool) routeBuilder {
rb.eventMatcher = fn
return rb
}
func (rb routeBuilder) Relay(relay *Relay) {
rb.router.routes = append(rb.router.routes, Route{
filterMatcher: rb.filterMatcher,
eventMatcher: rb.eventMatcher,
relay: relay,
})
}

View File

@@ -41,21 +41,14 @@ func GetAuthed(ctx context.Context) string {
}
func GetIP(ctx context.Context) string {
return GetIPFromRequest(GetConnection(ctx).Request)
conn := GetConnection(ctx)
if conn == nil {
return ""
}
return GetIPFromRequest(conn.Request)
}
func GetSubscriptionID(ctx context.Context) string {
return ctx.Value(subscriptionIdKey).(string)
}
func GetOpenSubscriptions(ctx context.Context) []nostr.Filter {
if subs, ok := listeners.Load(GetConnection(ctx)); ok {
res := make([]nostr.Filter, 0, listeners.Size()*2)
subs.Range(func(_ string, sub *Listener) bool {
res = append(res, sub.filters...)
return true
})
return res
}
return nil
}