mirror of
https://github.com/fiatjaf/khatru.git
synced 2026-04-07 14:06:51 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f79b883cb |
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -1,14 +0,0 @@
|
||||
name: test every commit
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: ./go.mod
|
||||
- run: go test ./...
|
||||
141
README.md
141
README.md
@@ -1,140 +1 @@
|
||||
# khatru, a relay framework [](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay)
|
||||
|
||||
[](https://github.com/fiatjaf/khatru/actions/workflows/test.yml)
|
||||
[](https://pkg.go.dev/github.com/fiatjaf/khatru)
|
||||
[](https://goreportcard.com/report/github.com/fiatjaf/khatru)
|
||||
|
||||
Khatru makes it easy to write very very custom relays:
|
||||
|
||||
- custom event or filter acceptance policies
|
||||
- custom `AUTH` handlers
|
||||
- custom storage and pluggable databases
|
||||
- custom webpages and other HTTP handlers
|
||||
|
||||
Here's a sample:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// create the relay instance
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
// set up some basic properties (will be returned on the NIP-11 endpoint)
|
||||
relay.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"
|
||||
|
||||
// you must bring your own storage scheme -- if you want to have any
|
||||
store := make(map[string]*nostr.Event, 120)
|
||||
|
||||
// set up the basic relay functions
|
||||
relay.StoreEvent = append(relay.StoreEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
store[event.ID] = event
|
||||
return nil
|
||||
},
|
||||
)
|
||||
relay.QueryEvents = append(relay.QueryEvents,
|
||||
func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
|
||||
ch := make(chan *nostr.Event)
|
||||
go func() {
|
||||
for _, evt := range store {
|
||||
if filter.Matches(evt) {
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
},
|
||||
)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
delete(store, event.ID)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
// there are many other configurable things you can set
|
||||
relay.RejectEvent = append(relay.RejectEvent,
|
||||
// built-in policies
|
||||
policies.ValidateKind,
|
||||
|
||||
// define your own policies
|
||||
policies.PreventLargeTags(100),
|
||||
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
|
||||
return true, "we don't allow this person to write here"
|
||||
}
|
||||
return false, "" // anyone else can
|
||||
},
|
||||
)
|
||||
|
||||
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
|
||||
relay.RejectFilter = append(relay.RejectFilter,
|
||||
// built-in policies
|
||||
policies.NoComplexFilters,
|
||||
|
||||
// define your own policies
|
||||
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
|
||||
log.Printf("request from %s\n", pubkey)
|
||||
return false, ""
|
||||
}
|
||||
return true, "auth-required: only authenticated users can read from this relay"
|
||||
// (this will cause an AUTH message to be sent and then a CLOSED message such that clients can
|
||||
// authenticate and then request again)
|
||||
},
|
||||
)
|
||||
// check the docs for more goodies!
|
||||
|
||||
mux := relay.Router()
|
||||
// set up other http handlers
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/html")
|
||||
fmt.Fprintf(w, `<b>welcome</b> to my relay!`)
|
||||
})
|
||||
|
||||
// start the server
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
}
|
||||
```
|
||||
|
||||
### But I don't want to write my own database!
|
||||
|
||||
Fear no more. Using the https://github.com/fiatjaf/eventstore module you get a bunch of compatible databases out of the box and you can just plug them into your relay. For example, [sqlite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3):
|
||||
|
||||
```go
|
||||
db := sqlite3.SQLite3Backend{DatabaseURL: "/tmp/khatru-sqlite-tmp"}
|
||||
if err := db.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
|
||||
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
|
||||
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
|
||||
```
|
||||
|
||||
### But I don't want to write a bunch of custom policies!
|
||||
|
||||
Fear no more. We have a bunch of common policies written in the `github.com/fiatjaf/khatru/policies` package and also a handpicked selection of base sane defaults, which you can apply with:
|
||||
|
||||
```go
|
||||
policies.ApplySaneDefaults(relay)
|
||||
```
|
||||
|
||||
Contributions to this are very much welcomed.
|
||||
khatru
|
||||
|
||||
138
add-event.go
Normal file
138
add-event.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
|
||||
if evt == nil {
|
||||
return fmt.Errorf("event is nil")
|
||||
}
|
||||
|
||||
msg := ""
|
||||
rejecting := false
|
||||
for _, reject := range rl.RejectEvent {
|
||||
rejecting, msg = reject(ctx, evt)
|
||||
if rejecting {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if rejecting {
|
||||
if msg == "" {
|
||||
msg = "no reason"
|
||||
}
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
|
||||
if 20000 <= evt.Kind && evt.Kind < 30000 {
|
||||
// do not store ephemeral events
|
||||
} else {
|
||||
if evt.Kind == 0 || evt.Kind == 3 || (10000 <= evt.Kind && evt.Kind < 20000) {
|
||||
// replaceable event, delete before storing
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
previous := <-ch
|
||||
if previous != nil {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if 30000 <= evt.Kind && evt.Kind < 40000 {
|
||||
// parameterized replaceable event, delete before storing
|
||||
d := evt.Tags.GetFirst([]string{"d", ""})
|
||||
if d != nil {
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{d.Value()}}})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
previous := <-ch
|
||||
if previous != nil {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store
|
||||
for _, store := range rl.StoreEvent {
|
||||
if saveErr := store(ctx, evt); saveErr != nil {
|
||||
switch saveErr {
|
||||
case ErrDupEvent:
|
||||
return nil
|
||||
default:
|
||||
errmsg := saveErr.Error()
|
||||
if nip20prefixmatcher.MatchString(errmsg) {
|
||||
return saveErr
|
||||
} else {
|
||||
return fmt.Errorf("error: failed to save (%s)", errmsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ons := range rl.OnEventSaved {
|
||||
ons(ctx, evt)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ovw := range rl.OverwriteResponseEvent {
|
||||
ovw(ctx, evt)
|
||||
}
|
||||
notifyListeners(evt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, nostr.Filter{IDs: []string{tag[1]}})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
target := <-ch
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
// got the event, now check if the user can delete it
|
||||
acceptDeletion := target.PubKey == evt.PubKey
|
||||
var msg string
|
||||
if acceptDeletion == false {
|
||||
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 {
|
||||
del(ctx, target)
|
||||
}
|
||||
} else {
|
||||
// fail and stop here
|
||||
return fmt.Errorf("blocked: %s", msg)
|
||||
}
|
||||
|
||||
// don't try to query this same event again
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
110
adding.go
110
adding.go
@@ -1,110 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fiatjaf/eventstore"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
for _, reject := range rl.RejectEvent {
|
||||
if reject, msg := reject(ctx, evt); reject {
|
||||
if msg == "" {
|
||||
return false, errors.New("blocked: no reason")
|
||||
} else {
|
||||
return false, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if 20000 <= evt.Kind && evt.Kind < 30000 {
|
||||
// do not store ephemeral events
|
||||
for _, oee := range rl.OnEphemeralEvent {
|
||||
oee(ctx, evt)
|
||||
}
|
||||
} else {
|
||||
// will store
|
||||
|
||||
// but first check if we already have it
|
||||
filter := nostr.Filter{IDs: []string{evt.ID}}
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for range ch {
|
||||
// if we run this it means we already have this event, so we just return a success and exit
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// if it's replaceable we first delete old versions
|
||||
if evt.Kind == 0 || evt.Kind == 3 || (10000 <= evt.Kind && evt.Kind < 20000) {
|
||||
// replaceable event, delete before storing
|
||||
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}}
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for previous := range ch {
|
||||
if isOlder(previous, evt) {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if 30000 <= evt.Kind && evt.Kind < 40000 {
|
||||
// parameterized replaceable event, delete before storing
|
||||
d := evt.Tags.GetFirst([]string{"d", ""})
|
||||
if d == nil {
|
||||
return false, fmt.Errorf("invalid: missing 'd' tag on parameterized replaceable event")
|
||||
}
|
||||
|
||||
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{(*d)[1]}}}
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for previous := range ch {
|
||||
if isOlder(previous, evt) {
|
||||
for _, del := range rl.DeleteEvent {
|
||||
del(ctx, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store
|
||||
for _, store := range rl.StoreEvent {
|
||||
if saveErr := store(ctx, evt); saveErr != nil {
|
||||
switch saveErr {
|
||||
case eventstore.ErrDupEvent:
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ons := range rl.OnEventSaved {
|
||||
ons(ctx, evt)
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
rl.notifyListeners(evt)
|
||||
}
|
||||
81
deleting.go
81
deleting.go
@@ -1,81 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) error {
|
||||
// event deletion -- nip09
|
||||
for _, tag := range evt.Tags {
|
||||
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, f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
target := <-ch
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
// got the event, now check if the user can delete it
|
||||
acceptDeletion := target.PubKey == evt.PubKey
|
||||
var msg string
|
||||
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 {
|
||||
if err := del(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// fail and stop here
|
||||
return fmt.Errorf("blocked: %s", msg)
|
||||
}
|
||||
|
||||
// don't try to query this same event again
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
@@ -1,9 +0,0 @@
|
||||
semi: false
|
||||
arrowParens: avoid
|
||||
insertPragma: false
|
||||
printWidth: 80
|
||||
proseWrap: preserve
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
useTabs: false
|
||||
bracketSpacing: false
|
||||
2
docs/.vitepress/.gitignore
vendored
2
docs/.vitepress/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
cache
|
||||
dist
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
@@ -1,24 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import NostrifyLayout from './Layout.vue'
|
||||
import './custom.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: NostrifyLayout
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
.vitepress/config.js
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
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"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Reacting to a successful authentication
|
||||
|
||||
Each `khatru.WebSocket` object has an `.Authed` channel that is closed whenever that connection performs a successful authentication.
|
||||
|
||||
You can use that to emulate a listener for these events in case you want to keep track of who is authenticating in real time and not only check it when they request for something.
|
||||
|
||||
```go
|
||||
relay.OnConnect = append(relay.OnConnect,
|
||||
khatru.RequestAuth,
|
||||
func(ctx context.Context) {
|
||||
go func(ctx context.Context) {
|
||||
conn := khatru.GetConnection(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("connection closed")
|
||||
case <-conn.Authed:
|
||||
fmt.Println("authenticated as", conn.AuthedPublicKey)
|
||||
}
|
||||
}(ctx)
|
||||
},
|
||||
)
|
||||
```
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
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
|
||||
}
|
||||
```
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
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.)
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
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)
|
||||
```
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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)
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
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(120), 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.
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
|
||||
dev:
|
||||
vitepress dev
|
||||
|
||||
build:
|
||||
vitepress build
|
||||
BIN
docs/logo.png
BIN
docs/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"vitepress": "^1.3.0"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
* [gm-relay](https://github.com/ptrio42/gm-relay), a relay that only accepts GM notes once a day.
|
||||
|
||||
## 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
10
docs/why.md
@@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
5
errors.go
Normal file
5
errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package khatru
|
||||
|
||||
import "fmt"
|
||||
|
||||
var ErrDupEvent = fmt.Errorf("duplicate: event already exists")
|
||||
@@ -20,7 +20,6 @@ func main() {
|
||||
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
|
||||
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
|
||||
relay.Negentropy = true
|
||||
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/eventstore/elasticsearch"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/eventstore/elasticsearch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/eventstore/postgresql"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/eventstore/postgresql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/eventstore/sqlite3"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/eventstore/sqlite3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/fiatjaf/eventstore/lmdb"
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/khatru/policies"
|
||||
"github.com/fiatjaf/khatru/plugins"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ func main() {
|
||||
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
|
||||
|
||||
relay.RejectEvent = append(relay.RejectEvent, policies.PreventTooManyIndexableTags(10, nil, nil))
|
||||
relay.RejectFilter = append(relay.RejectFilter, policies.NoComplexFilters)
|
||||
relay.RejectEvent = append(relay.RejectEvent, plugins.PreventTooManyIndexableTags(10))
|
||||
relay.RejectFilter = append(relay.RejectFilter, plugins.NoPrefixFilters, plugins.NoComplexFilters)
|
||||
|
||||
relay.OnEventSaved = append(relay.OnEventSaved, func(ctx context.Context, event *nostr.Event) {
|
||||
})
|
||||
|
||||
Binary file not shown.
@@ -1,98 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/fiatjaf/khatru/policies"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// create the relay instance
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
// set up some basic properties (will be returned on the NIP-11 endpoint)
|
||||
relay.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"
|
||||
|
||||
// you must bring your own storage scheme -- if you want to have any
|
||||
store := make(map[string]*nostr.Event, 120)
|
||||
|
||||
// set up the basic relay functions
|
||||
relay.StoreEvent = append(relay.StoreEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
store[event.ID] = event
|
||||
return nil
|
||||
},
|
||||
)
|
||||
relay.QueryEvents = append(relay.QueryEvents,
|
||||
func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
|
||||
ch := make(chan *nostr.Event)
|
||||
go func() {
|
||||
for _, evt := range store {
|
||||
if filter.Matches(evt) {
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
},
|
||||
)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent,
|
||||
func(ctx context.Context, event *nostr.Event) error {
|
||||
delete(store, event.ID)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
// there are many other configurable things you can set
|
||||
relay.RejectEvent = append(relay.RejectEvent,
|
||||
// built-in policies
|
||||
policies.ValidateKind,
|
||||
|
||||
// define your own policies
|
||||
policies.PreventLargeTags(100),
|
||||
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
|
||||
return true, "we don't allow this person to write here"
|
||||
}
|
||||
return false, "" // anyone else can
|
||||
},
|
||||
)
|
||||
|
||||
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
|
||||
relay.RejectFilter = append(relay.RejectFilter,
|
||||
// built-in policies
|
||||
policies.NoComplexFilters,
|
||||
|
||||
// define your own policies
|
||||
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
|
||||
log.Printf("request from %s\n", pubkey)
|
||||
return false, ""
|
||||
}
|
||||
return true, "auth-required: only authenticated users can read from this relay"
|
||||
// (this will cause an AUTH message to be sent and then a CLOSED message such that clients can
|
||||
// authenticate and then request again)
|
||||
},
|
||||
)
|
||||
// check the docs for more goodies!
|
||||
|
||||
mux := relay.Router()
|
||||
// set up other http handlers
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/html")
|
||||
fmt.Fprintf(w, `<b>welcome</b> to my relay!`)
|
||||
})
|
||||
|
||||
// start the server
|
||||
fmt.Println("running on :3334")
|
||||
http.ListenAndServe(":3334", relay)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
}
|
||||
68
go.mod
68
go.mod
@@ -1,62 +1,56 @@
|
||||
module github.com/fiatjaf/khatru
|
||||
|
||||
go 1.23.1
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/fasthttp/websocket v1.5.7
|
||||
github.com/fiatjaf/eventstore v0.12.0
|
||||
github.com/nbd-wtf/go-nostr v0.40.0
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
github.com/fasthttp/websocket v1.5.3
|
||||
github.com/fiatjaf/eventstore v0.1.0
|
||||
github.com/gobwas/ws v1.2.0
|
||||
github.com/nbd-wtf/go-nostr v0.25.1
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1
|
||||
github.com/rs/cors v1.7.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.2.0 // indirect
|
||||
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.4 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.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.3.1 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/bmatsuo/lmdb-go v1.8.0 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // 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/dgraph-io/badger/v4 v4.2.0 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/elastic/elastic-transport-go/v8 v8.3.0 // indirect
|
||||
github.com/elastic/go-elasticsearch/v7 v7.17.10 // indirect
|
||||
github.com/elastic/go-elasticsearch/v7 v7.6.0 // indirect
|
||||
github.com/elastic/go-elasticsearch/v8 v8.10.1 // indirect
|
||||
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.4.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/greatroar/blobloom v0.8.0 // indirect
|
||||
github.com/golang/glog v1.0.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.3 // indirect
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/jmoiron/sqlx v1.3.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.17.10 // indirect
|
||||
github.com/klauspost/compress v1.16.5 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // 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.3 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // 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-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/valyala/fasthttp v1.47.0 // indirect
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
)
|
||||
|
||||
208
go.sum
208
go.sum
@@ -1,100 +1,75 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
|
||||
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PowerDNS/lmdb-go v1.9.2 h1:Cmgerh9y3ZKBZGz1irxSShhfmFyRUh+Zdk4cZk7ZJvU=
|
||||
github.com/PowerDNS/lmdb-go v1.9.2/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
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 v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/bmatsuo/lmdb-go v1.8.0 h1:ohf3Q4xjXZBKh4AayUY4bb2CXuhRAI8BYGlJq08EfNA=
|
||||
github.com/bmatsuo/lmdb-go v1.8.0/go.mod h1:wWPZmKdOAZsl4qOqkowQ1aCrFie1HU8gWloHMCeAUdM=
|
||||
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/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
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.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.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
|
||||
github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
|
||||
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
|
||||
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/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=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.3.0 h1:DJGxovyQLXGr62e9nDMPSxRyWION0Bh6d9eCFBriiHo=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.3.0/go.mod h1:87Tcz8IVNe6rVSLdBux1o/PEItLtyabHU3naC7IoqKI=
|
||||
github.com/elastic/go-elasticsearch/v7 v7.6.0 h1:sYpGLpEFHgLUKLsZUBfuaVI9QgHjS3JdH9fX4/z8QI8=
|
||||
github.com/elastic/go-elasticsearch/v7 v7.6.0/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
|
||||
github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo=
|
||||
github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.10.1 h1:JJ3i2DimYTsJcUoEGbg6tNB0eehTNdid9c5kTR1TGuI=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.10.1/go.mod h1:GU1BJHO7WeamP7UhuElYwzzHtvf9SDmeVpSSy9+o6Qg=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
|
||||
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
|
||||
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
|
||||
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fiatjaf/eventstore v0.12.0 h1:ZdL+dZkIgBgIp5A3+3XLdPg/uucv5Tiws6DHzNfZG4M=
|
||||
github.com/fiatjaf/eventstore v0.12.0/go.mod h1:PxeYbZ3MsH0XLobANsp6c0cJjJYkfmBJ3TwrplFy/08=
|
||||
github.com/fiatjaf/eventstore v0.1.0 h1:/g7VTw6dsXmjICD3rBuHNIvAammHJ5unrKJ71Dz+VTs=
|
||||
github.com/fiatjaf/eventstore v0.1.0/go.mod h1:juMei5HL3HJi6t7vZjj7VdEItDPu31+GLROepdUK4tw=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
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=
|
||||
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.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I=
|
||||
github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
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=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
|
||||
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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.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/greatroar/blobloom v0.8.0 h1:I9RlEkfqK9/6f1v9mFmDYegDQ/x0mISCpiNpAm23Pt4=
|
||||
github.com/greatroar/blobloom v0.8.0/go.mod h1:mjMJ1hh1wjGVfr93QIHJ6FfDNVrA0IELv8OvMHJxHKs=
|
||||
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/jgroeneveld/schema v1.0.0 h1:J0E10CrOkiSEsw6dfb1IfrDJD14pf6QLVJ3tRPl/syI=
|
||||
github.com/jgroeneveld/schema v1.0.0/go.mod h1:M14lv7sNMtGvo3ops1MwslaSYgDYxrSmbzWIQ0Mr5rs=
|
||||
github.com/jgroeneveld/trial v2.0.0+incompatible h1:d59ctdgor+VqdZCAiUfVN8K13s0ALDioG5DWwZNtRuQ=
|
||||
@@ -105,66 +80,52 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
|
||||
github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
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=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/nbd-wtf/go-nostr v0.40.0 h1:ea7FlOsm4kO1071Tm4OT0lXTcyleiZCT9Ll4XERjTZw=
|
||||
github.com/nbd-wtf/go-nostr v0.40.0/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/nbd-wtf/go-nostr v0.25.1 h1:YTLTDUgngfzd3qQ0fWmQmq20flwnGtHH0g0Q8S3HlW4=
|
||||
github.com/nbd-wtf/go-nostr v0.25.1/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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.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/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
|
||||
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=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
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.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
|
||||
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
|
||||
github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
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=
|
||||
@@ -178,29 +139,30 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
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=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
@@ -211,29 +173,15 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
469
handlers.go
469
handlers.go
@@ -3,54 +3,39 @@ package khatru
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bep/debounce"
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip11"
|
||||
"github.com/nbd-wtf/go-nostr/nip42"
|
||||
"github.com/nbd-wtf/go-nostr/nip77"
|
||||
"github.com/nbd-wtf/go-nostr/nip77/negentropy"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
// ServeHTTP implements http.Handler interface.
|
||||
func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if rl.ServiceURL == "" {
|
||||
rl.ServiceURL = getServiceBaseURL(r)
|
||||
}
|
||||
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
rl.HandleWebsocket(w, r)
|
||||
} else if r.Header.Get("Accept") == "application/nostr+json" {
|
||||
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
|
||||
} else if r.Header.Get("Content-Type") == "application/nostr+json+rpc" {
|
||||
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r)
|
||||
rl.HandleNIP11(w, r)
|
||||
} else {
|
||||
rl.serveMux.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
for _, reject := range rl.RejectConnection {
|
||||
if reject(r) {
|
||||
w.WriteHeader(429) // Too many requests
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := rl.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
rl.Log.Printf("failed to upgrade websocket: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
rl.clients.Store(conn, struct{}{})
|
||||
ticker := time.NewTicker(rl.PingPeriod)
|
||||
|
||||
// NIP-42 challenge
|
||||
@@ -58,44 +43,26 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
rand.Read(challenge)
|
||||
|
||||
ws := &WebSocket{
|
||||
conn: conn,
|
||||
Request: r,
|
||||
Challenge: hex.EncodeToString(challenge),
|
||||
negentropySessions: xsync.NewMapOf[string, *NegentropySession](),
|
||||
}
|
||||
ws.Context, ws.cancel = context.WithCancel(context.Background())
|
||||
|
||||
rl.clientsMutex.Lock()
|
||||
rl.clients[ws] = make([]listenerSpec, 0, 2)
|
||||
rl.clientsMutex.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(
|
||||
context.WithValue(
|
||||
context.Background(),
|
||||
wsKey, ws,
|
||||
),
|
||||
)
|
||||
|
||||
kill := func() {
|
||||
for _, ondisconnect := range rl.OnDisconnect {
|
||||
ondisconnect(ctx)
|
||||
}
|
||||
|
||||
ticker.Stop()
|
||||
cancel()
|
||||
ws.cancel()
|
||||
ws.conn.Close()
|
||||
|
||||
rl.removeClientAndListeners(ws)
|
||||
conn: conn,
|
||||
Challenge: hex.EncodeToString(challenge),
|
||||
WaitingForAuth: make(chan struct{}),
|
||||
}
|
||||
|
||||
// reader
|
||||
go func() {
|
||||
defer kill()
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
if _, ok := rl.clients.Load(conn); ok {
|
||||
conn.Close()
|
||||
rl.clients.Delete(conn)
|
||||
removeListener(ws)
|
||||
}
|
||||
}()
|
||||
|
||||
ws.conn.SetReadLimit(rl.MaxMessageSize)
|
||||
ws.conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
||||
ws.conn.SetPongHandler(func(string) error {
|
||||
ws.conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
||||
conn.SetReadLimit(rl.MaxMessageSize)
|
||||
conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -104,20 +71,17 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for {
|
||||
typ, message, err := ws.conn.ReadMessage()
|
||||
typ, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(
|
||||
err,
|
||||
websocket.CloseNormalClosure, // 1000
|
||||
websocket.CloseGoingAway, // 1001
|
||||
websocket.CloseNoStatusReceived, // 1005
|
||||
websocket.CloseAbnormalClosure, // 1006
|
||||
4537, // some client seems to send many of these
|
||||
) {
|
||||
rl.Log.Printf("unexpected close error from %s: %v\n", r.Header.Get("X-Forwarded-For"), err)
|
||||
}
|
||||
ws.cancel()
|
||||
return
|
||||
break
|
||||
}
|
||||
|
||||
if typ == websocket.PingMessage {
|
||||
@@ -126,253 +90,246 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
go func(message []byte) {
|
||||
envelope := nostr.ParseMessage(message)
|
||||
if envelope == nil {
|
||||
if !rl.Negentropy {
|
||||
// stop silently
|
||||
return
|
||||
}
|
||||
envelope = nip77.ParseNegMessage(message)
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
ctx = context.Background()
|
||||
|
||||
var request []json.RawMessage
|
||||
if err := json.Unmarshal(message, &request); err != nil {
|
||||
// stop silently
|
||||
return
|
||||
}
|
||||
|
||||
switch env := envelope.(type) {
|
||||
case *nostr.EventEnvelope:
|
||||
// check id
|
||||
if !env.Event.CheckID() {
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: id is computed incorrectly"})
|
||||
if len(request) < 2 {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("request has less than 2 parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
var typ string
|
||||
json.Unmarshal(request[0], &typ)
|
||||
|
||||
switch typ {
|
||||
case "EVENT":
|
||||
// it's a new event
|
||||
var evt nostr.Event
|
||||
if err := json.Unmarshal(request[1], &evt); err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode event: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// check signature
|
||||
if ok, err := env.Event.CheckSignature(); err != nil {
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to verify signature"})
|
||||
// check serialization
|
||||
serialized := evt.Serialize()
|
||||
|
||||
// assign ID
|
||||
hash := sha256.Sum256(serialized)
|
||||
evt.ID = hex.EncodeToString(hash[:])
|
||||
|
||||
// check signature (requires the ID to be set)
|
||||
if ok, err := evt.CheckSignature(); err != nil {
|
||||
reason := "error: failed to verify signature"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
return
|
||||
} else if !ok {
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: signature is invalid"})
|
||||
reason := "invalid: signature is invalid"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
return
|
||||
}
|
||||
|
||||
// check NIP-70 protected
|
||||
for _, v := range env.Event.Tags {
|
||||
if len(v) == 1 && v[0] == "-" {
|
||||
msg := "must be published by event author"
|
||||
authed := GetAuthed(ctx)
|
||||
if authed == "" {
|
||||
RequestAuth(ctx)
|
||||
ws.WriteJSON(nostr.OKEnvelope{
|
||||
EventID: env.Event.ID,
|
||||
OK: false,
|
||||
Reason: "auth-required: " + msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
if authed != env.Event.PubKey {
|
||||
ws.WriteJSON(nostr.OKEnvelope{
|
||||
EventID: env.Event.ID,
|
||||
OK: false,
|
||||
Reason: "blocked: " + msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = srl.handleDeleteRequest(ctx, &env.Event)
|
||||
if evt.Kind == 5 {
|
||||
err = rl.handleDeleteRequest(ctx, &evt)
|
||||
} else {
|
||||
// this will also always return a prefixed reason
|
||||
skipBroadcast, writeErr = srl.AddEvent(ctx, &env.Event)
|
||||
err = rl.AddEvent(ctx, &evt)
|
||||
}
|
||||
|
||||
var reason string
|
||||
if writeErr == nil {
|
||||
var reason *string
|
||||
if err == nil {
|
||||
ok = true
|
||||
for _, ovw := range srl.OverwriteResponseEvent {
|
||||
ovw(ctx, &env.Event)
|
||||
}
|
||||
if !skipBroadcast {
|
||||
srl.notifyListeners(&env.Event)
|
||||
}
|
||||
} else {
|
||||
reason = writeErr.Error()
|
||||
if strings.HasPrefix(reason, "auth-required:") {
|
||||
RequestAuth(ctx)
|
||||
}
|
||||
msg := err.Error()
|
||||
reason = &msg
|
||||
}
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: ok, Reason: reason})
|
||||
case *nostr.CountEnvelope:
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: ok, Reason: reason})
|
||||
case "COUNT":
|
||||
if rl.CountEvents == nil {
|
||||
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: "unsupported: this relay does not support NIP-45"})
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("this relay does not support NIP-45"))
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
json.Unmarshal(request[1], &id)
|
||||
if id == "" {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("COUNT has no <id>"))
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, filter := range env.Filters {
|
||||
srl := rl
|
||||
if rl.getSubRelayFromFilter != nil {
|
||||
srl = rl.getSubRelayFromFilter(filter)
|
||||
filters := make(nostr.Filters, len(request)-2)
|
||||
for i, filterReq := range request[2:] {
|
||||
if err := json.Unmarshal(filterReq, &filters[i]); err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode filter"))
|
||||
continue
|
||||
}
|
||||
total += srl.handleCountRequest(ctx, ws, filter)
|
||||
}
|
||||
ws.WriteJSON(nostr.CountEnvelope{SubscriptionID: env.SubscriptionID, Count: &total})
|
||||
case *nostr.ReqEnvelope:
|
||||
eose := sync.WaitGroup{}
|
||||
eose.Add(len(env.Filters))
|
||||
|
||||
// a context just for the "stored events" request handler
|
||||
reqCtx, cancelReqCtx := context.WithCancelCause(ctx)
|
||||
filter := filters[i]
|
||||
|
||||
// expose subscription id in the context
|
||||
reqCtx = context.WithValue(reqCtx, subscriptionIdKey, env.SubscriptionID)
|
||||
|
||||
// handle each filter separately -- dispatching events as they're loaded from databases
|
||||
for _, filter := range env.Filters {
|
||||
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()
|
||||
if strings.HasPrefix(reason, "auth-required:") {
|
||||
RequestAuth(ctx)
|
||||
for _, reject := range rl.RejectFilter {
|
||||
if rejecting, msg := reject(ctx, filter); rejecting {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(msg))
|
||||
continue
|
||||
}
|
||||
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: reason})
|
||||
cancelReqCtx(errors.New("filter rejected"))
|
||||
return
|
||||
} else {
|
||||
rl.addListener(ws, env.SubscriptionID, srl, filter, cancelReqCtx)
|
||||
}
|
||||
for _, reject := range rl.RejectCountFilter {
|
||||
if rejecting, msg := reject(ctx, filter); rejecting {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(msg))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, count := range rl.CountEvents {
|
||||
res, err := count(ctx, filter)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
|
||||
}
|
||||
total += res
|
||||
}
|
||||
}
|
||||
|
||||
ws.WriteJSON([]interface{}{"COUNT", id, map[string]int64{"count": total}})
|
||||
case "REQ":
|
||||
var id string
|
||||
json.Unmarshal(request[1], &id)
|
||||
if id == "" {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("REQ has no <id>"))
|
||||
return
|
||||
}
|
||||
|
||||
filters := make(nostr.Filters, len(request)-2)
|
||||
eose := sync.WaitGroup{}
|
||||
eose.Add(len(request[2:]))
|
||||
|
||||
for i, filterReq := range request[2:] {
|
||||
if err := json.Unmarshal(
|
||||
filterReq,
|
||||
&filters[i],
|
||||
); err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode filter"))
|
||||
eose.Done()
|
||||
continue
|
||||
}
|
||||
|
||||
filter := filters[i]
|
||||
|
||||
for _, reject := range rl.RejectCountFilter {
|
||||
if rejecting, msg := reject(ctx, filter); rejecting {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(msg))
|
||||
eose.Done()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
eose.Add(len(rl.QueryEvents))
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
|
||||
eose.Done()
|
||||
continue
|
||||
}
|
||||
|
||||
go func(ch chan *nostr.Event) {
|
||||
for event := range ch {
|
||||
for _, ovw := range rl.OverwriteResponseEvent {
|
||||
ovw(ctx, event)
|
||||
}
|
||||
ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &id, Event: *event})
|
||||
}
|
||||
eose.Done()
|
||||
}(ch)
|
||||
}
|
||||
|
||||
eose.Done()
|
||||
}
|
||||
|
||||
go func() {
|
||||
// when all events have been loaded from databases and dispatched
|
||||
// we can cancel the context and fire the EOSE message
|
||||
eose.Wait()
|
||||
cancelReqCtx(nil)
|
||||
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
|
||||
ws.WriteJSON(nostr.EOSEEnvelope(id))
|
||||
}()
|
||||
case *nostr.CloseEnvelope:
|
||||
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 {
|
||||
ws.AuthedPublicKey = pubkey
|
||||
ws.authLock.Lock()
|
||||
if ws.Authed != nil {
|
||||
close(ws.Authed)
|
||||
ws.Authed = nil
|
||||
}
|
||||
ws.authLock.Unlock()
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true})
|
||||
} else {
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to authenticate"})
|
||||
|
||||
setListener(id, ws, filters)
|
||||
case "CLOSE":
|
||||
var id string
|
||||
json.Unmarshal(request[1], &id)
|
||||
if id == "" {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("CLOSE has no <id>"))
|
||||
return
|
||||
}
|
||||
case *nip77.OpenEnvelope:
|
||||
srl := rl
|
||||
if rl.getSubRelayFromFilter != nil {
|
||||
srl = rl.getSubRelayFromFilter(env.Filter)
|
||||
if !srl.Negentropy {
|
||||
// ignore
|
||||
|
||||
removeListenerId(ws, id)
|
||||
case "AUTH":
|
||||
if rl.ServiceURL != "" {
|
||||
var evt nostr.Event
|
||||
if err := json.Unmarshal(request[1], &evt); err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode auth event: " + err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
vec, err := srl.startNegentropySession(ctx, env.Filter)
|
||||
if err != nil {
|
||||
// fail everything if any filter is rejected
|
||||
reason := err.Error()
|
||||
if strings.HasPrefix(reason, "auth-required:") {
|
||||
RequestAuth(ctx)
|
||||
if pubkey, ok := nip42.ValidateAuthEvent(&evt, ws.Challenge, rl.ServiceURL); ok {
|
||||
ws.Authed = pubkey
|
||||
close(ws.WaitingForAuth)
|
||||
ctx = context.WithValue(ctx, AUTH_CONTEXT_KEY, pubkey)
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: true})
|
||||
} else {
|
||||
reason := "error: failed to authenticate"
|
||||
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: &reason})
|
||||
}
|
||||
ws.WriteJSON(nip77.ErrorEnvelope{SubscriptionID: env.SubscriptionID, Reason: reason})
|
||||
return
|
||||
}
|
||||
|
||||
// reconcile to get the next message and return it
|
||||
neg := negentropy.New(vec, 1024*1024)
|
||||
out, err := neg.Reconcile(env.Message)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nip77.ErrorEnvelope{SubscriptionID: env.SubscriptionID, Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
ws.WriteJSON(nip77.MessageEnvelope{SubscriptionID: env.SubscriptionID, Message: out})
|
||||
|
||||
// if the message is not empty that means we'll probably have more reconciliation sessions, so store this
|
||||
if out != "" {
|
||||
deb := debounce.New(time.Second * 7)
|
||||
negSession := &NegentropySession{
|
||||
neg: neg,
|
||||
postponeClose: func() {
|
||||
deb(func() {
|
||||
ws.negentropySessions.Delete(env.SubscriptionID)
|
||||
})
|
||||
},
|
||||
}
|
||||
negSession.postponeClose()
|
||||
|
||||
ws.negentropySessions.Store(env.SubscriptionID, negSession)
|
||||
}
|
||||
case *nip77.MessageEnvelope:
|
||||
negSession, ok := ws.negentropySessions.Load(env.SubscriptionID)
|
||||
if !ok {
|
||||
// bad luck, your request was destroyed
|
||||
ws.WriteJSON(nip77.ErrorEnvelope{SubscriptionID: env.SubscriptionID, Reason: "CLOSED"})
|
||||
return
|
||||
}
|
||||
// reconcile to get the next message and return it
|
||||
out, err := negSession.neg.Reconcile(env.Message)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nip77.ErrorEnvelope{SubscriptionID: env.SubscriptionID, Reason: err.Error()})
|
||||
ws.negentropySessions.Delete(env.SubscriptionID)
|
||||
return
|
||||
}
|
||||
ws.WriteJSON(nip77.MessageEnvelope{SubscriptionID: env.SubscriptionID, Message: out})
|
||||
|
||||
// if there is more reconciliation to do, postpone this
|
||||
if out != "" {
|
||||
negSession.postponeClose()
|
||||
} else {
|
||||
// otherwise we can just close it
|
||||
ws.negentropySessions.Delete(env.SubscriptionID)
|
||||
}
|
||||
case *nip77.CloseEnvelope:
|
||||
ws.negentropySessions.Delete(env.SubscriptionID)
|
||||
}
|
||||
}(message)
|
||||
}
|
||||
}()
|
||||
|
||||
// writer
|
||||
go func() {
|
||||
defer kill()
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := ws.WriteMessage(websocket.PingMessage, nil)
|
||||
if err != nil {
|
||||
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
|
||||
rl.Log.Printf("error writing ping: %v; closing websocket\n", err)
|
||||
}
|
||||
rl.Log.Printf("error writing ping: %v; closing websocket\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
supportedNIPs := []int{9, 11, 12, 15, 16, 20, 33}
|
||||
if rl.ServiceURL != "" {
|
||||
supportedNIPs = append(supportedNIPs, 42)
|
||||
}
|
||||
if rl.CountEvents != nil {
|
||||
supportedNIPs = append(supportedNIPs, 45)
|
||||
}
|
||||
|
||||
info := nip11.RelayInformationDocument{
|
||||
Name: rl.Name,
|
||||
Description: rl.Description,
|
||||
PubKey: rl.PubKey,
|
||||
Contact: rl.Contact,
|
||||
Icon: rl.IconURL,
|
||||
SupportedNIPs: supportedNIPs,
|
||||
Software: "https://github.com/trailriver/khatru",
|
||||
Version: "n/a",
|
||||
}
|
||||
|
||||
for _, edit := range rl.EditInformation {
|
||||
edit(r.Context(), &info)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
77
helpers.go
77
helpers.go
@@ -1,77 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func isOlder(previous, next *nostr.Event) bool {
|
||||
return previous.CreatedAt < next.CreatedAt ||
|
||||
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
|
||||
}
|
||||
|
||||
func getServiceBaseURL(r *http.Request) string {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if host == "localhost" {
|
||||
proto = "http"
|
||||
} else if strings.Index(host, ":") != -1 {
|
||||
// has a port number
|
||||
proto = "http"
|
||||
} else if _, err := strconv.Atoi(strings.ReplaceAll(host, ".", "")); err == nil {
|
||||
// it's a naked IP
|
||||
proto = "http"
|
||||
} else {
|
||||
proto = "https"
|
||||
}
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
var privateMasks = func() []net.IPNet {
|
||||
privateCIDRs := []string{
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"fc00::/7",
|
||||
}
|
||||
masks := make([]net.IPNet, len(privateCIDRs))
|
||||
for i, cidr := range privateCIDRs {
|
||||
_, netw, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
masks[i] = *netw
|
||||
}
|
||||
return masks
|
||||
}()
|
||||
|
||||
func isPrivate(ip net.IP) bool {
|
||||
for _, mask := range privateMasks {
|
||||
if mask.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetIPFromRequest(r *http.Request) string {
|
||||
if xffh := r.Header.Get("X-Forwarded-For"); xffh != "" {
|
||||
for _, v := range strings.Split(xffh, ",") {
|
||||
if ip := net.ParseIP(strings.TrimSpace(v)); ip != nil && ip.IsGlobalUnicast() && !isPrivate(ip) {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return ip
|
||||
}
|
||||
195
listener.go
195
listener.go
@@ -1,146 +1,79 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/puzpuzpuz/xsync/v2"
|
||||
)
|
||||
|
||||
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
|
||||
type Listener struct {
|
||||
filters nostr.Filters
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
id string // duplicated here so we can easily send it on notifyListeners
|
||||
filter nostr.Filter
|
||||
ws *WebSocket
|
||||
}
|
||||
var listeners = xsync.NewTypedMapOf[*WebSocket, *xsync.MapOf[string, *Listener]](pointerHasher[WebSocket])
|
||||
|
||||
func (rl *Relay) GetListeningFilters() []nostr.Filter {
|
||||
respfilters := make([]nostr.Filter, len(rl.listeners))
|
||||
for i, l := range rl.listeners {
|
||||
respfilters[i] = l.filter
|
||||
}
|
||||
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
|
||||
return respfilters
|
||||
}
|
||||
|
||||
// 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()
|
||||
func setListener(id string, ws *WebSocket, filters nostr.Filters) {
|
||||
subs, _ := listeners.LoadOrCompute(ws, func() *xsync.MapOf[string, *Listener] {
|
||||
return xsync.NewMapOf[*Listener]()
|
||||
})
|
||||
subs.Store(id, &Listener{filters: filters})
|
||||
}
|
||||
|
||||
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,
|
||||
// Remove a specific subscription id from listeners for a given ws client
|
||||
func removeListenerId(ws *WebSocket, id string) {
|
||||
if subs, ok := listeners.Load(ws); ok {
|
||||
subs.Delete(id)
|
||||
if subs.Size() == 0 {
|
||||
listeners.Delete(ws)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove WebSocket conn from listeners
|
||||
func removeListener(ws *WebSocket) {
|
||||
listeners.Delete(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
|
||||
}
|
||||
ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &id, Event: *event})
|
||||
return true
|
||||
})
|
||||
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 (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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (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
|
||||
}
|
||||
}
|
||||
listener.ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &listener.id, Event: *event})
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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
545
listener_test.go
@@ -1,545 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip77/negentropy"
|
||||
"github.com/nbd-wtf/go-nostr/nip77/negentropy/storage/vector"
|
||||
)
|
||||
|
||||
type NegentropySession struct {
|
||||
neg *negentropy.Negentropy
|
||||
postponeClose func()
|
||||
}
|
||||
|
||||
func (rl *Relay) startNegentropySession(ctx context.Context, filter nostr.Filter) (*vector.Vector, error) {
|
||||
// do the same overwrite/reject flow we do in normal REQs
|
||||
for _, ovw := range rl.OverwriteFilter {
|
||||
ovw(ctx, &filter)
|
||||
}
|
||||
if filter.LimitZero {
|
||||
return nil, fmt.Errorf("invalid limit 0")
|
||||
}
|
||||
for _, reject := range rl.RejectFilter {
|
||||
if reject, msg := reject(ctx, filter); reject {
|
||||
return nil, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
|
||||
}
|
||||
}
|
||||
|
||||
// fetch events and add them to a negentropy Vector store
|
||||
vec := vector.New()
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
} else if ch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for event := range ch {
|
||||
// since the goal here is to sync databases we won't do fancy stuff like overwrite events
|
||||
vec.Insert(event.CreatedAt, event.ID)
|
||||
}
|
||||
}
|
||||
vec.Seal()
|
||||
|
||||
return vec, nil
|
||||
}
|
||||
28
nip11.go
28
nip11.go
@@ -1,28 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/nostr+json")
|
||||
|
||||
info := *rl.Info
|
||||
|
||||
if len(rl.DeleteEvent) > 0 {
|
||||
info.AddSupportedNIP(9)
|
||||
}
|
||||
if len(rl.CountEvents) > 0 {
|
||||
info.AddSupportedNIP(45)
|
||||
}
|
||||
if rl.Negentropy {
|
||||
info.AddSupportedNIP(77)
|
||||
}
|
||||
|
||||
for _, ovw := range rl.OverwriteRelayInformation {
|
||||
info = ovw(r.Context(), r, info)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
270
nip86.go
270
nip86.go
@@ -1,270 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip86"
|
||||
)
|
||||
|
||||
type RelayManagementAPI struct {
|
||||
RejectAPICall []func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string)
|
||||
|
||||
BanPubKey func(ctx context.Context, pubkey string, reason string) error
|
||||
ListBannedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
|
||||
AllowPubKey func(ctx context.Context, pubkey string, reason string) error
|
||||
ListAllowedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
|
||||
ListEventsNeedingModeration func(ctx context.Context) ([]nip86.IDReason, error)
|
||||
AllowEvent func(ctx context.Context, id string, reason string) error
|
||||
BanEvent func(ctx context.Context, id string, reason string) error
|
||||
ListBannedEvents func(ctx context.Context) ([]nip86.IDReason, error)
|
||||
ChangeRelayName func(ctx context.Context, name string) error
|
||||
ChangeRelayDescription func(ctx context.Context, desc string) error
|
||||
ChangeRelayIcon func(ctx context.Context, icon string) error
|
||||
AllowKind func(ctx context.Context, kind int) error
|
||||
DisallowKind func(ctx context.Context, kind int) error
|
||||
ListAllowedKinds func(ctx context.Context) ([]int, error)
|
||||
BlockIP func(ctx context.Context, ip net.IP, reason string) error
|
||||
UnblockIP func(ctx context.Context, ip net.IP, reason string) error
|
||||
ListBlockedIPs func(ctx context.Context) ([]nip86.IPReason, error)
|
||||
}
|
||||
|
||||
func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/nostr+json+rpc")
|
||||
|
||||
var (
|
||||
resp nip86.Response
|
||||
ctx = r.Context()
|
||||
req nip86.Request
|
||||
mp nip86.MethodParams
|
||||
evt nostr.Event
|
||||
payloadHash [32]byte
|
||||
)
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
resp.Error = "empty request"
|
||||
goto respond
|
||||
}
|
||||
payloadHash = sha256.Sum256(payload)
|
||||
|
||||
{
|
||||
auth := r.Header.Get("Authorization")
|
||||
spl := strings.Split(auth, "Nostr ")
|
||||
if len(spl) != 2 {
|
||||
resp.Error = "missing auth"
|
||||
goto respond
|
||||
}
|
||||
if evtj, err := base64.StdEncoding.DecodeString(spl[1]); err != nil {
|
||||
resp.Error = "invalid base64 auth"
|
||||
goto respond
|
||||
} else if err := json.Unmarshal(evtj, &evt); err != nil {
|
||||
resp.Error = "invalid auth event json"
|
||||
goto respond
|
||||
} 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
|
||||
} else if evt.CreatedAt < nostr.Now()-30 {
|
||||
resp.Error = "auth event is too old"
|
||||
goto respond
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
resp.Error = "invalid json body"
|
||||
goto respond
|
||||
}
|
||||
|
||||
mp, err = nip86.DecodeRequest(req)
|
||||
if err != nil {
|
||||
resp.Error = fmt.Sprintf("invalid params: %s", err)
|
||||
goto respond
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, nip86HeaderAuthKey, evt.PubKey)
|
||||
for _, rac := range rl.ManagementAPI.RejectAPICall {
|
||||
if reject, msg := rac(ctx, mp); reject {
|
||||
resp.Error = msg
|
||||
goto respond
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := mp.(nip86.SupportedMethods); ok {
|
||||
mat := reflect.TypeOf(rl.ManagementAPI)
|
||||
mav := reflect.ValueOf(rl.ManagementAPI)
|
||||
|
||||
methods := make([]string, 0, mat.NumField())
|
||||
for i := 0; i < mat.NumField(); i++ {
|
||||
field := mat.Field(i)
|
||||
|
||||
// danger: this assumes the struct fields are appropriately named
|
||||
methodName := strings.ToLower(field.Name)
|
||||
|
||||
// assign this only if the function was defined
|
||||
if mav.Field(i).Interface() != nil {
|
||||
methods[i] = methodName
|
||||
}
|
||||
}
|
||||
resp.Result = methods
|
||||
} else {
|
||||
switch thing := mp.(type) {
|
||||
case nip86.BanPubKey:
|
||||
if rl.ManagementAPI.BanPubKey == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.BanPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ListBannedPubKeys:
|
||||
if rl.ManagementAPI.ListBannedPubKeys == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListBannedPubKeys(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
case nip86.AllowPubKey:
|
||||
if rl.ManagementAPI.AllowPubKey == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.AllowPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ListAllowedPubKeys:
|
||||
if rl.ManagementAPI.ListAllowedPubKeys == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListAllowedPubKeys(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
case nip86.BanEvent:
|
||||
if rl.ManagementAPI.BanEvent == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.BanEvent(ctx, thing.ID, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.AllowEvent:
|
||||
if rl.ManagementAPI.AllowEvent == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.AllowEvent(ctx, thing.ID, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ListEventsNeedingModeration:
|
||||
if rl.ManagementAPI.ListEventsNeedingModeration == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListEventsNeedingModeration(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
case nip86.ListBannedEvents:
|
||||
if rl.ManagementAPI.ListBannedEvents == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListEventsNeedingModeration(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
case nip86.ChangeRelayName:
|
||||
if rl.ManagementAPI.ChangeRelayName == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.ChangeRelayName(ctx, thing.Name); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ChangeRelayDescription:
|
||||
if rl.ManagementAPI.ChangeRelayDescription == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.ChangeRelayDescription(ctx, thing.Description); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ChangeRelayIcon:
|
||||
if rl.ManagementAPI.ChangeRelayIcon == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.ChangeRelayIcon(ctx, thing.IconURL); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.AllowKind:
|
||||
if rl.ManagementAPI.AllowKind == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.AllowKind(ctx, thing.Kind); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.DisallowKind:
|
||||
if rl.ManagementAPI.DisallowKind == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.DisallowKind(ctx, thing.Kind); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ListAllowedKinds:
|
||||
if rl.ManagementAPI.ListAllowedKinds == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListAllowedKinds(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
case nip86.BlockIP:
|
||||
if rl.ManagementAPI.BlockIP == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.BlockIP(ctx, thing.IP, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.UnblockIP:
|
||||
if rl.ManagementAPI.UnblockIP == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if err := rl.ManagementAPI.UnblockIP(ctx, thing.IP, thing.Reason); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = true
|
||||
}
|
||||
case nip86.ListBlockedIPs:
|
||||
if rl.ManagementAPI.ListBlockedIPs == nil {
|
||||
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
|
||||
} else if result, err := rl.ManagementAPI.ListBlockedIPs(ctx); err != nil {
|
||||
resp.Error = err.Error()
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
default:
|
||||
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
|
||||
}
|
||||
}
|
||||
|
||||
respond:
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
58
plugins/events.go
Normal file
58
plugins/events.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject
|
||||
// events with more indexable (single-character) tags than the specified number.
|
||||
func PreventTooManyIndexableTags(max int) func(context.Context, *nostr.Event) (bool, string) {
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
ntags := 0
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) > 0 && len(tag[0]) == 1 {
|
||||
ntags++
|
||||
}
|
||||
}
|
||||
if ntags > max {
|
||||
return true, "too many indexable tags"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// RestrictToSpecifiedKinds returns a function that can be used as a RejectFilter that will reject
|
||||
// any events with kinds different than the specified ones.
|
||||
func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) {
|
||||
max := 0
|
||||
min := 0
|
||||
allowed := make(map[uint16]struct{}, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
allowed[kind] = struct{}{}
|
||||
if int(kind) > max {
|
||||
max = int(kind)
|
||||
}
|
||||
if int(kind) < min {
|
||||
min = int(kind)
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
// these are cheap and very questionable optimizations, but they exist for a reason:
|
||||
// we would have to ensure that the kind number is within the bounds of a uint16 anyway
|
||||
if event.Kind > max {
|
||||
return true, "event kind not allowed"
|
||||
}
|
||||
if event.Kind < min {
|
||||
return true, "event kind not allowed"
|
||||
}
|
||||
|
||||
// hopefully this map of uint16s is very fast
|
||||
if _, allowed := allowed[uint16(event.Kind)]; allowed {
|
||||
return false, ""
|
||||
}
|
||||
return true, "event kind not allowed"
|
||||
}
|
||||
}
|
||||
33
plugins/filters.go
Normal file
33
plugins/filters.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func NoPrefixFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
for _, id := range filter.IDs {
|
||||
if len(id) != 64 {
|
||||
return true, fmt.Sprintf("filters can only contain full ids")
|
||||
}
|
||||
}
|
||||
for _, pk := range filter.Authors {
|
||||
if len(pk) != 64 {
|
||||
return true, fmt.Sprintf("filters can only contain full pubkeys")
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
items := len(filter.Tags) + len(filter.Kinds)
|
||||
|
||||
if items > 4 && len(filter.Tags) > 2 {
|
||||
return true, "too many things to filter for"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package policies
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// RejectKind04Snoopers prevents reading NIP-04 messages from people not involved in the conversation.
|
||||
func RejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, string) {
|
||||
func rejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, string) {
|
||||
// prevent kind-4 events from being returned to unauthed users,
|
||||
// only when authentication is a thing
|
||||
if !slices.Contains(filter.Kinds, 4) {
|
||||
@@ -21,13 +19,13 @@ func RejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, strin
|
||||
senders := filter.Authors
|
||||
receivers, _ := filter.Tags["p"]
|
||||
switch {
|
||||
case ws.AuthedPublicKey == "":
|
||||
case ws.Authed == "":
|
||||
// not authenticated
|
||||
return true, "restricted: this relay does not serve kind-4 to unauthenticated users, does your client implement NIP-42?"
|
||||
case len(senders) == 1 && len(receivers) < 2 && (senders[0] == ws.AuthedPublicKey):
|
||||
case len(senders) == 1 && len(receivers) < 2 && (senders[0] == ws.Authed):
|
||||
// allowed filter: ws.authed is sole sender (filter specifies one or all receivers)
|
||||
return false, ""
|
||||
case len(receivers) == 1 && len(senders) < 2 && (receivers[0] == ws.AuthedPublicKey):
|
||||
case len(receivers) == 1 && len(senders) < 2 && (receivers[0] == ws.Authed):
|
||||
// allowed filter: ws.authed is sole receiver (filter specifies one or all senders)
|
||||
return false, ""
|
||||
default:
|
||||
@@ -1,109 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject
|
||||
// events with more indexable (single-character) tags than the specified number.
|
||||
//
|
||||
// If ignoreKinds is given this restriction will not apply to these kinds (useful for allowing a bigger).
|
||||
// If onlyKinds is given then all other kinds will be ignored.
|
||||
func PreventTooManyIndexableTags(max int, ignoreKinds []int, onlyKinds []int) func(context.Context, *nostr.Event) (bool, string) {
|
||||
slices.Sort(ignoreKinds)
|
||||
slices.Sort(onlyKinds)
|
||||
|
||||
ignore := func(kind int) bool { return false }
|
||||
if len(ignoreKinds) > 0 {
|
||||
ignore = func(kind int) bool {
|
||||
_, isIgnored := slices.BinarySearch(ignoreKinds, kind)
|
||||
return isIgnored
|
||||
}
|
||||
}
|
||||
if len(onlyKinds) > 0 {
|
||||
ignore = func(kind int) bool {
|
||||
_, isApplicable := slices.BinarySearch(onlyKinds, kind)
|
||||
return !isApplicable
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if ignore(event.Kind) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
ntags := 0
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) > 0 && len(tag[0]) == 1 {
|
||||
ntags++
|
||||
}
|
||||
}
|
||||
if ntags > max {
|
||||
return true, "too many indexable tags"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// PreventLargeTags rejects events that have indexable tag values greater than maxTagValueLen.
|
||||
func PreventLargeTags(maxTagValueLen int) func(context.Context, *nostr.Event) (bool, string) {
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
for _, tag := range event.Tags {
|
||||
if len(tag) > 1 && len(tag[0]) == 1 {
|
||||
if len(tag[1]) > maxTagValueLen {
|
||||
return true, "event contains too large tags"
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// RestrictToSpecifiedKinds returns a function that can be used as a RejectFilter that will reject
|
||||
// any events with kinds different than the specified ones.
|
||||
func RestrictToSpecifiedKinds(allowEphemeral bool, kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) {
|
||||
// sort the kinds in increasing order
|
||||
slices.Sort(kinds)
|
||||
|
||||
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
|
||||
if allowEphemeral && event.IsEphemeral() {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if _, allowed := slices.BinarySearch(kinds, uint16(event.Kind)); allowed {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, fmt.Sprintf("received event kind %d not allowed", event.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func RejectEventsWithBase64Media(ctx context.Context, evt *nostr.Event) (bool, string) {
|
||||
return strings.Contains(evt.Content, "data:image/") || strings.Contains(evt.Content, "data:video/"), "event with base64 media"
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// NoComplexFilters disallows filters with more than 2 tags.
|
||||
func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
items := len(filter.Tags) + len(filter.Kinds)
|
||||
|
||||
if items > 4 && len(filter.Tags) > 2 {
|
||||
return true, "too many things to filter for"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// NoEmptyFilters disallows filters that don't have at least a tag, a kind, an author or an id.
|
||||
func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
c := len(filter.Kinds) + len(filter.IDs) + len(filter.Authors)
|
||||
for _, tagItems := range filter.Tags {
|
||||
c += len(tagItems)
|
||||
}
|
||||
if c == 0 {
|
||||
return true, "can't handle empty filters"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// AntiSyncBots tries to prevent people from syncing kind:1s from this relay to else by always
|
||||
// requiring an author parameter at least.
|
||||
func AntiSyncBots(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
return (len(filter.Kinds) == 0 || slices.Contains(filter.Kinds, 1)) &&
|
||||
len(filter.Authors) == 0, "an author must be specified to get their kind:1 notes"
|
||||
}
|
||||
|
||||
func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
|
||||
if filter.Search != "" {
|
||||
return true, "search is not supported"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func RemoveSearchQueries(ctx context.Context, filter *nostr.Filter) {
|
||||
if filter.Search != "" {
|
||||
filter.Search = ""
|
||||
filter.LimitZero = true // signals that this query should be just skipped
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveAllButKinds(kinds ...uint16) func(context.Context, *nostr.Filter) {
|
||||
return func(ctx context.Context, filter *nostr.Filter) {
|
||||
if n := len(filter.Kinds); n > 0 {
|
||||
newKinds := make([]int, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if k := filter.Kinds[i]; slices.Contains(kinds, uint16(k)) {
|
||||
newKinds = append(newKinds, k)
|
||||
}
|
||||
}
|
||||
filter.Kinds = newKinds
|
||||
if len(filter.Kinds) == 0 {
|
||||
filter.LimitZero = true // signals that this query should be just skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveAllButTags(tagNames ...string) func(context.Context, *nostr.Filter) {
|
||||
return func(ctx context.Context, filter *nostr.Filter) {
|
||||
if n := len(filter.Tags); n > 0 {
|
||||
for tagName := range filter.Tags {
|
||||
if !slices.Contains(tagNames, tagName) {
|
||||
delete(filter.Tags, tagName)
|
||||
}
|
||||
}
|
||||
if len(filter.Tags) == 0 {
|
||||
filter.LimitZero = true // signals that this query should be just skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
)
|
||||
|
||||
func startRateLimitSystem[K comparable](
|
||||
tokensPerInterval int,
|
||||
interval time.Duration,
|
||||
maxTokens int,
|
||||
) func(key K) (ratelimited bool) {
|
||||
negativeBuckets := xsync.NewMapOf[K, *atomic.Int32]()
|
||||
maxTokensInt32 := int32(maxTokens)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
for key, bucket := range negativeBuckets.Range {
|
||||
newv := bucket.Add(int32(-tokensPerInterval))
|
||||
if newv <= 0 {
|
||||
negativeBuckets.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func(key K) bool {
|
||||
nb, _ := negativeBuckets.LoadOrStore(key, &atomic.Int32{})
|
||||
|
||||
if nb.Load() < maxTokensInt32 {
|
||||
nb.Add(1)
|
||||
// rate limit not reached yet
|
||||
return false
|
||||
}
|
||||
|
||||
// rate limit reached
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func ValidateKind(ctx context.Context, evt *nostr.Event) (bool, string) {
|
||||
switch evt.Kind {
|
||||
case 0:
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal([]byte(evt.Content), &m)
|
||||
if m.Name == "" {
|
||||
return true, "missing json name in kind 0"
|
||||
}
|
||||
case 1:
|
||||
return false, ""
|
||||
case 2:
|
||||
return true, "this kind has been deprecated"
|
||||
}
|
||||
|
||||
// TODO: all other kinds
|
||||
|
||||
return false, ""
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func EventIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
|
||||
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
|
||||
|
||||
return func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
|
||||
ip := khatru.GetIP(ctx)
|
||||
if ip == "" {
|
||||
return false, ""
|
||||
}
|
||||
return rl(ip), "rate-limited: slow down, please"
|
||||
}
|
||||
}
|
||||
|
||||
func EventPubKeyRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
|
||||
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
|
||||
|
||||
return func(ctx context.Context, evt *nostr.Event) (reject bool, msg string) {
|
||||
return rl(evt.PubKey), "rate-limited: slow down, please"
|
||||
}
|
||||
}
|
||||
|
||||
func ConnectionRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(r *http.Request) bool {
|
||||
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
|
||||
|
||||
return func(r *http.Request) bool {
|
||||
return rl(khatru.GetIPFromRequest(r))
|
||||
}
|
||||
}
|
||||
|
||||
func FilterIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
|
||||
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
|
||||
|
||||
return func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
|
||||
return rl(khatru.GetIP(ctx)), "rate-limited: there is a bug in the client, no one should be making so many requests"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package policies
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/fiatjaf/khatru"
|
||||
)
|
||||
|
||||
func ApplySaneDefaults(relay *khatru.Relay) {
|
||||
relay.RejectEvent = append(relay.RejectEvent,
|
||||
RejectEventsWithBase64Media,
|
||||
EventIPRateLimiter(2, time.Minute*3, 5),
|
||||
)
|
||||
|
||||
relay.RejectFilter = append(relay.RejectFilter,
|
||||
NoComplexFilters,
|
||||
FilterIPRateLimiter(20, time.Minute, 100),
|
||||
)
|
||||
|
||||
relay.RejectConnection = append(relay.RejectConnection,
|
||||
ConnectionRateLimiter(1, time.Minute*5, 10),
|
||||
)
|
||||
}
|
||||
79
relay.go
79
relay.go
@@ -5,33 +5,25 @@ 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/v2"
|
||||
)
|
||||
|
||||
func NewRelay() *Relay {
|
||||
rl := &Relay{
|
||||
return &Relay{
|
||||
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
|
||||
|
||||
Info: &nip11.RelayInformationDocument{
|
||||
Software: "https://github.com/fiatjaf/khatru",
|
||||
Version: "n/a",
|
||||
SupportedNIPs: []int{1, 11, 42, 70, 86},
|
||||
},
|
||||
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
|
||||
clients: make(map[*WebSocket][]listenerSpec, 100),
|
||||
listeners: make([]listener, 0, 100),
|
||||
|
||||
clients: xsync.NewTypedMapOf[*websocket.Conn, struct{}](pointerHasher[websocket.Conn]),
|
||||
serveMux: &http.ServeMux{},
|
||||
|
||||
WriteWait: 10 * time.Second,
|
||||
@@ -39,43 +31,29 @@ func NewRelay() *Relay {
|
||||
PingPeriod: 30 * time.Second,
|
||||
MaxMessageSize: 512000,
|
||||
}
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
ServiceURL string
|
||||
Name string
|
||||
Description string
|
||||
PubKey string
|
||||
Contact string
|
||||
ServiceURL string // required for nip-42
|
||||
IconURL string
|
||||
|
||||
// hooks that will be called at various times
|
||||
RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string)
|
||||
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
|
||||
StoreEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
DeleteEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
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
|
||||
|
||||
// editing info will affect the NIP-11 responses
|
||||
Info *nip11.RelayInformationDocument
|
||||
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)
|
||||
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
|
||||
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
|
||||
StoreEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
DeleteEvent []func(ctx context.Context, event *nostr.Event) error
|
||||
QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
|
||||
CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error)
|
||||
EditInformation []func(ctx context.Context, info *nip11.RelayInformationDocument)
|
||||
OnAuth []func(ctx context.Context, pubkey string)
|
||||
OnConnect []func(ctx context.Context)
|
||||
OnEventSaved []func(ctx context.Context, event *nostr.Event)
|
||||
|
||||
// Default logger, as set by NewServer, is a stdlib logger prefixed with "[khatru-relay] ",
|
||||
// outputting to stderr.
|
||||
@@ -85,13 +63,7 @@ type Relay struct {
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
// keep a connection reference to all connected clients for Server.Shutdown
|
||||
// also used for keeping track of who is listening to what
|
||||
clients map[*WebSocket][]listenerSpec
|
||||
listeners []listener
|
||||
clientsMutex sync.Mutex
|
||||
|
||||
// set this to true to support negentropy
|
||||
Negentropy bool
|
||||
clients *xsync.MapOf[*websocket.Conn, struct{}]
|
||||
|
||||
// in case you call Server.Start
|
||||
Addr string
|
||||
@@ -104,3 +76,8 @@ type Relay struct {
|
||||
PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait.
|
||||
MaxMessageSize int64 // Maximum message size allowed from peer.
|
||||
}
|
||||
|
||||
func (rl *Relay) RequestAuth(ctx context.Context) {
|
||||
ws := GetConnection(ctx)
|
||||
ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge})
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) error {
|
||||
defer eose.Done()
|
||||
|
||||
// overwrite the filter (for example, to eliminate some kinds or
|
||||
// that we know we don't support)
|
||||
for _, ovw := range rl.OverwriteFilter {
|
||||
ovw(ctx, &filter)
|
||||
}
|
||||
|
||||
if filter.LimitZero {
|
||||
// don't do any queries, just subscribe to future events
|
||||
return nil
|
||||
}
|
||||
|
||||
// then check if we'll reject this filter (we apply this after overwriting
|
||||
// because we may, for example, remove some things from the incoming filters
|
||||
// that we know we don't support, and then if the end result is an empty
|
||||
// filter we can just reject it)
|
||||
for _, reject := range rl.RejectFilter {
|
||||
if reject, msg := reject(ctx, filter); reject {
|
||||
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
|
||||
}
|
||||
}
|
||||
|
||||
// run the functions to query events (generally just one,
|
||||
// but we might be fetching stuff from multiple places)
|
||||
eose.Add(len(rl.QueryEvents))
|
||||
for _, query := range rl.QueryEvents {
|
||||
ch, err := query(ctx, filter)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
|
||||
eose.Done()
|
||||
continue
|
||||
} else if ch == nil {
|
||||
eose.Done()
|
||||
continue
|
||||
}
|
||||
|
||||
go func(ch chan *nostr.Event) {
|
||||
for event := range ch {
|
||||
for _, ovw := range rl.OverwriteResponseEvent {
|
||||
ovw(ctx, event)
|
||||
}
|
||||
ws.WriteJSON(nostr.EventEnvelope{SubscriptionID: &id, Event: *event})
|
||||
}
|
||||
eose.Done()
|
||||
}(ch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rl *Relay) handleCountRequest(ctx context.Context, ws *WebSocket, filter nostr.Filter) int64 {
|
||||
// overwrite the filter (for example, to eliminate some kinds or tags that we know we don't support)
|
||||
for _, ovw := range rl.OverwriteCountFilter {
|
||||
ovw(ctx, &filter)
|
||||
}
|
||||
|
||||
// then check if we'll reject this filter
|
||||
for _, reject := range rl.RejectCountFilter {
|
||||
if rejecting, msg := reject(ctx, filter); rejecting {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(msg))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// run the functions to count (generally it will be just one)
|
||||
var subtotal int64 = 0
|
||||
for _, count := range rl.CountEvents {
|
||||
res, err := count(ctx, filter)
|
||||
if err != nil {
|
||||
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
|
||||
}
|
||||
subtotal += res
|
||||
}
|
||||
|
||||
return subtotal
|
||||
}
|
||||
67
router.go
67
router.go
@@ -1,67 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -49,13 +49,11 @@ 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.clientsMutex.Lock()
|
||||
defer rl.clientsMutex.Unlock()
|
||||
for ws := range rl.clients {
|
||||
ws.conn.WriteControl(websocket.CloseMessage, nil, time.Now().Add(time.Second))
|
||||
ws.cancel()
|
||||
ws.conn.Close()
|
||||
}
|
||||
clear(rl.clients)
|
||||
rl.listeners = rl.listeners[:0]
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
93
start_test.go
Normal file
93
start_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gobwas/ws/wsutil"
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func TestServerStartShutdown(t *testing.T) {
|
||||
var (
|
||||
inited bool
|
||||
storeInited bool
|
||||
shutdown bool
|
||||
)
|
||||
rl := &testRelay{
|
||||
name: "test server start",
|
||||
init: func() error {
|
||||
inited = true
|
||||
return nil
|
||||
},
|
||||
onShutdown: func(context.Context) { shutdown = true },
|
||||
storage: &testStorage{
|
||||
init: func() error { storeInited = true; return nil },
|
||||
},
|
||||
}
|
||||
srv, _ := NewServer(rl)
|
||||
ready := make(chan bool)
|
||||
done := make(chan error)
|
||||
go func() { done <- srv.Start("127.0.0.1", 0, ready); close(done) }()
|
||||
<-ready
|
||||
|
||||
// verify everything's initialized
|
||||
if !inited {
|
||||
t.Error("didn't call testRelay.init")
|
||||
}
|
||||
if !storeInited {
|
||||
t.Error("didn't call testStorage.init")
|
||||
}
|
||||
|
||||
// check that http requests are served
|
||||
if _, err := http.Get("http://" + srv.Addr); err != nil {
|
||||
t.Errorf("GET %s: %v", srv.Addr, err)
|
||||
}
|
||||
|
||||
// verify server shuts down
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
if !shutdown {
|
||||
t.Error("didn't call testRelay.onShutdown")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("srv.Start: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("srv.Start too long to return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerShutdownWebsocket(t *testing.T) {
|
||||
// set up a new relay server
|
||||
srv := startTestRelay(t, &testRelay{storage: &testStorage{}})
|
||||
|
||||
// connect a client to it
|
||||
ctx1, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
client, err := nostr.RelayConnect(ctx1, "ws://"+srv.Addr)
|
||||
if err != nil {
|
||||
t.Fatalf("nostr.RelayConnectContext: %v", err)
|
||||
}
|
||||
|
||||
// now, shut down the server
|
||||
ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx2)
|
||||
|
||||
// wait for the client to receive a "connection close"
|
||||
time.Sleep(1 * time.Second)
|
||||
err = client.ConnectionError
|
||||
if e := errors.Unwrap(err); e != nil {
|
||||
err = e
|
||||
}
|
||||
if _, ok := err.(wsutil.ClosedError); !ok {
|
||||
t.Errorf("client.ConnextionError: %v (%T); want wsutil.ClosedError", err, err)
|
||||
}
|
||||
}
|
||||
91
util_test.go
Normal file
91
util_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
func startTestRelay(t *testing.T, tr *testRelay) *Server {
|
||||
t.Helper()
|
||||
srv, _ := NewServer(tr)
|
||||
started := make(chan bool)
|
||||
go srv.Start("127.0.0.1", 0, started)
|
||||
<-started
|
||||
return srv
|
||||
}
|
||||
|
||||
type testRelay struct {
|
||||
name string
|
||||
storage Storage
|
||||
init func() error
|
||||
onShutdown func(context.Context)
|
||||
acceptEvent func(*nostr.Event) bool
|
||||
}
|
||||
|
||||
func (tr *testRelay) Name() string { return tr.name }
|
||||
func (tr *testRelay) Storage(context.Context) Storage { return tr.storage }
|
||||
|
||||
func (tr *testRelay) Init() error {
|
||||
if fn := tr.init; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tr *testRelay) OnShutdown(ctx context.Context) {
|
||||
if fn := tr.onShutdown; fn != nil {
|
||||
fn(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *testRelay) AcceptEvent(ctx context.Context, e *nostr.Event) bool {
|
||||
if fn := tr.acceptEvent; fn != nil {
|
||||
return fn(e)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type testStorage struct {
|
||||
init func() error
|
||||
queryEvents func(context.Context, *nostr.Filter) (chan *nostr.Event, error)
|
||||
deleteEvent func(ctx context.Context, id string, pubkey string) error
|
||||
saveEvent func(context.Context, *nostr.Event) error
|
||||
countEvents func(context.Context, *nostr.Filter) (int64, error)
|
||||
}
|
||||
|
||||
func (st *testStorage) Init() error {
|
||||
if fn := st.init; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *testStorage) QueryEvents(ctx context.Context, f *nostr.Filter) (chan *nostr.Event, error) {
|
||||
if fn := st.queryEvents; fn != nil {
|
||||
return fn(ctx, f)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (st *testStorage) DeleteEvent(ctx context.Context, id string, pubkey string) error {
|
||||
if fn := st.deleteEvent; fn != nil {
|
||||
return fn(ctx, id, pubkey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *testStorage) SaveEvent(ctx context.Context, e *nostr.Event) error {
|
||||
if fn := st.saveEvent; fn != nil {
|
||||
return fn(ctx, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *testStorage) CountEvents(ctx context.Context, f *nostr.Filter) (int64, error) {
|
||||
if fn := st.countEvents; fn != nil {
|
||||
return fn(ctx, f)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
47
utils.go
47
utils.go
@@ -2,53 +2,28 @@ package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"hash/maphash"
|
||||
"regexp"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
wsKey = iota
|
||||
subscriptionIdKey
|
||||
nip86HeaderAuthKey
|
||||
AUTH_CONTEXT_KEY = iota
|
||||
WS_KEY = iota
|
||||
)
|
||||
|
||||
func RequestAuth(ctx context.Context) {
|
||||
ws := GetConnection(ctx)
|
||||
ws.authLock.Lock()
|
||||
if ws.Authed == nil {
|
||||
ws.Authed = make(chan struct{})
|
||||
}
|
||||
ws.authLock.Unlock()
|
||||
ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge})
|
||||
}
|
||||
var nip20prefixmatcher = regexp.MustCompile(`^\w+: `)
|
||||
|
||||
func GetConnection(ctx context.Context) *WebSocket {
|
||||
wsi := ctx.Value(wsKey)
|
||||
if wsi != nil {
|
||||
return wsi.(*WebSocket)
|
||||
}
|
||||
return nil
|
||||
return ctx.Value(WS_KEY).(*WebSocket)
|
||||
}
|
||||
|
||||
func GetAuthed(ctx context.Context) string {
|
||||
if conn := GetConnection(ctx); conn != nil {
|
||||
return conn.AuthedPublicKey
|
||||
}
|
||||
if nip86Auth := ctx.Value(nip86HeaderAuthKey); nip86Auth != nil {
|
||||
return nip86Auth.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetIP(ctx context.Context) string {
|
||||
conn := GetConnection(ctx)
|
||||
if conn == nil {
|
||||
authedPubkey := ctx.Value(AUTH_CONTEXT_KEY)
|
||||
if authedPubkey == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return GetIPFromRequest(conn.Request)
|
||||
return authedPubkey.(string)
|
||||
}
|
||||
|
||||
func GetSubscriptionID(ctx context.Context) string {
|
||||
return ctx.Value(subscriptionIdKey).(string)
|
||||
}
|
||||
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 { return uint64(uintptr(unsafe.Pointer(k))) }
|
||||
|
||||
21
websocket.go
21
websocket.go
@@ -1,34 +1,19 @@
|
||||
package khatru
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
)
|
||||
|
||||
type WebSocket struct {
|
||||
conn *websocket.Conn
|
||||
mutex sync.Mutex
|
||||
|
||||
// original request
|
||||
Request *http.Request
|
||||
|
||||
// this Context will be canceled whenever the connection is closed from the client side or server-side.
|
||||
Context context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// nip42
|
||||
Challenge string
|
||||
AuthedPublicKey string
|
||||
Authed chan struct{}
|
||||
|
||||
// nip77
|
||||
negentropySessions *xsync.MapOf[string, *NegentropySession]
|
||||
|
||||
authLock sync.Mutex
|
||||
Challenge string
|
||||
Authed string
|
||||
WaitingForAuth chan struct{}
|
||||
}
|
||||
|
||||
func (ws *WebSocket) WriteJSON(any any) error {
|
||||
|
||||
Reference in New Issue
Block a user