merge from github.com/fiatjaf/relay29

This commit is contained in:
water783 2024-09-14 11:02:22 +08:00
parent 4033a871c4
commit 9cd490bd84
25 changed files with 1741 additions and 275 deletions

View File

@ -1,35 +1,79 @@
### Ensure Go is installed on your server
= relay29 image:https://pkg.go.dev/badge/github.com/fiatjaf/relay29.svg[link=https://pkg.go.dev/github.com/fiatjaf/relay29]
### Pull the code from Git
NIP-29 requires the relays to have more of an active role in making groups work with the rules, so this is a library for creating NIP-29 relays, works with https://github.com/fiatjaf/khatru[khatru] using the https://pkg.go.dev/github.com/fiatjaf/relay29/khatru29[khatru29] wrapper, https://github.com/hoytech/strfry[strfry] with link:strfry29[strfry29] and https://github.com/fiatjaf/relayer[relayer] with link:relayer29[relayer29].
```sh
git clone https://github.com/0xchat-app/relay29
```
CAUTION: This is probably broken so please don't trust it for anything serious and be prepared to delete your database.
### Set environment variables
[source,go]
----
package main
```sh
export PORT=
export DOMAIN=
export RELAY_NAME=
export RELAY_PRIVKEY=
```
import (
"fmt"
"log"
"net/http"
"time"
### Build the project
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relay29/khatru29"
"github.com/nbd-wtf/go-nostr"
)
```sh
cd relay29/groups.0xchat.com
go build -o groupRelay
```
func main() {
relayPrivateKey := nostr.GeneratePrivateKey()
### Execute the binary
db := &slicestore.SliceStore{} // this only keeps things in memory, use a different eventstore in production
db.Init()
```sh
./groupRelay
```
relay, _ := khatru29.Init(relay29.Options{
Domain: "localhost:2929",
DB: db,
SecretKey: relayPrivateKey,
})
### Connect to the server
// init relay
relay.Info.Name = "very ephemeral chat relay"
relay.Info.Description = "everything will be deleted as soon as I turn off my computer"
```sh
ws://127.0.0.1:5577 (default port)
```
// extra policies
relay.RejectEvent = append(relay.RejectEvent,
policies.PreventLargeTags(64),
policies.PreventTooManyIndexableTags(6, []int{9005}, nil),
policies.RestrictToSpecifiedKinds(
9, 10, 11, 12,
30023, 31922, 31923, 9802,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9021,
),
policies.PreventTimestampsInThePast(60 * time.Second),
policies.PreventTimestampsInTheFuture(30 * time.Second),
)
// http routes
relay.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "nothing to see here, you must use a nip-29 powered client")
})
fmt.Println("running on http://0.0.0.0:2929")
if err := http.ListenAndServe(":2929", relay); err != nil {
log.Fatal("failed to serve")
}
}
----
== How to use
Basically you just call `khatru29.Init()` and then you get back a `khatru.Relay` and a `relay29.State` instances. The state has inside it also a map of `Group` objects that you can read but you should not modify manually. To modify these groups you must write moderation events with the `.AddEvent()` method of the `Relay`. This API may be improved later.
See link:examples/groups.fiatjaf.com/main.go[] for a (not very much) more complex example.
== How it works
What this library does is basically:
- it keeps a list of of groups with metadata in memory (not the messages);
- it checks a bunch of stuff for every event and filter received;
- it acts on moderation events and on join-request events received and modify the group state;
- it generates group metadata events (39000, 39001, 39002) events on the fly (these are not stored) and returns them to whoever queries them;
- on startup it loads all the moderation events (9000, 9001, etc) from the database and rebuilds the group state from that (so if you want to modify the group state permanently you must publish one of these events to the relay — but of course you can also monkey-patch the map of groups in memory like an animal if you want);

View File

@ -6,13 +6,12 @@ import (
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
nip29_relay "github.com/nbd-wtf/go-nostr/nip29/relay"
"github.com/rs/zerolog/log"
)
const tooOld = 60 // seconds
func (s *State) requireHTagForExistingGroup(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
func (s *State) RequireHTagForExistingGroup(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// this allows us to never check again in any of the other rules if the tag exists and just assume it exists always
gtag := event.Tags.GetFirst([]string{"h", ""})
if gtag == nil {
@ -32,7 +31,7 @@ func (s *State) requireHTagForExistingGroup(ctx context.Context, event *nostr.Ev
return false, ""
}
func (s *State) restrictWritesBasedOnGroupRules(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
func (s *State) RestrictWritesBasedOnGroupRules(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
group := s.GetGroupFromEvent(event)
if event.Kind == nostr.KindSimpleGroupJoinRequest {
@ -70,14 +69,14 @@ func (s *State) restrictWritesBasedOnGroupRules(ctx context.Context, event *nost
return false, ""
}
func (s *State) preventWritingOfEventsJustDeleted(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
func (s *State) PreventWritingOfEventsJustDeleted(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if s.deletedCache.Has(event.ID) {
return true, "this was deleted"
}
return false, ""
}
func (s *State) requireModerationEventsToBeRecent(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
func (s *State) RequireModerationEventsToBeRecent(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// moderation action events must be new and not reused
if nip29.ModerationEventKinds.Includes(event.Kind) && event.CreatedAt < nostr.Now()-tooOld {
return true, "moderation action is too old (older than 1 minute ago)"
@ -85,7 +84,7 @@ func (s *State) requireModerationEventsToBeRecent(ctx context.Context, event *no
return false, ""
}
func (s *State) restrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
func (s *State) RestrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if !nip29.ModerationEventKinds.Includes(event.Kind) {
return false, ""
}
@ -102,17 +101,13 @@ func (s *State) restrictInvalidModerationActions(ctx context.Context, event *nos
return false, ""
}
action, err := nip29_relay.GetModerationAction(event)
action, err := PrepareModerationAction(event)
if err != nil {
return true, "invalid moderation action: " + err.Error()
}
// remove self from group
if event.Kind == nostr.KindSimpleGroupRemoveUser {
users := GetUsersFromEvent(event)
if len(users) == 1 && event.PubKey == users[0] {
return false, ""
}
if egs, ok := action.(EditGroupStatus); ok && egs.Private && !s.AllowPrivateGroups {
return true, "groups cannot be private"
}
group.mu.RLock()
@ -127,9 +122,9 @@ func (s *State) restrictInvalidModerationActions(ctx context.Context, event *nos
return false, ""
}
func (s *State) applyModerationAction(ctx context.Context, event *nostr.Event) {
func (s *State) ApplyModerationAction(ctx context.Context, event *nostr.Event) {
// turn event into a moderation action processor
action, err := nip29_relay.GetModerationAction(event)
action, err := PrepareModerationAction(event)
if err != nil {
return
}
@ -144,6 +139,7 @@ func (s *State) applyModerationAction(ctx context.Context, event *nostr.Event) {
} else {
group = s.GetGroupFromEvent(event)
}
// apply the moderation action
group.mu.Lock()
action.Apply(&group.Group)
@ -176,6 +172,9 @@ func (s *State) applyModerationAction(ctx context.Context, event *nostr.Event) {
}
}
}
} else if event.Kind == nostr.KindSimpleGroupDeleteGroup {
// when the group was deleted we just remove it
s.Groups.Delete(group.Address.ID)
}
// propagate new replaceable events to listeners depending on what changed happened
@ -206,16 +205,17 @@ func (s *State) applyModerationAction(ctx context.Context, event *nostr.Event) {
},
}[event.Kind] {
evt := toBroadcast()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
s.Relay.BroadcastEvent(evt)
}
}
func (s *State) reactToJoinRequest(ctx context.Context, event *nostr.Event) {
func (s *State) ReactToJoinRequest(ctx context.Context, event *nostr.Event) {
if event.Kind != nostr.KindSimpleGroupJoinRequest {
return
}
// if the group is open, anyone requesting to join will be allowed
group := s.GetGroupFromEvent(event)
if !group.Closed {
// immediately add the requester
@ -227,7 +227,7 @@ func (s *State) reactToJoinRequest(ctx context.Context, event *nostr.Event) {
nostr.Tag{"p", event.PubKey},
},
}
if err := addUser.Sign(s.privateKey); err != nil {
if err := addUser.Sign(s.secretKey); err != nil {
log.Error().Err(err).Msg("failed to sign add-user event")
return
}
@ -238,3 +238,32 @@ func (s *State) reactToJoinRequest(ctx context.Context, event *nostr.Event) {
s.Relay.BroadcastEvent(addUser)
}
}
func (s *State) ReactToLeaveRequest(ctx context.Context, event *nostr.Event) {
if event.Kind != nostr.KindSimpleGroupLeaveRequest {
return
}
group := s.GetGroupFromEvent(event)
if _, isMember := group.Members[event.PubKey]; isMember {
// immediately remove the requester
removeUser := &nostr.Event{
CreatedAt: nostr.Now(),
Kind: nostr.KindSimpleGroupRemoveUser,
Tags: nostr.Tags{
nostr.Tag{"h", group.Address.ID},
nostr.Tag{"p", event.PubKey},
},
}
if err := removeUser.Sign(s.secretKey); err != nil {
log.Error().Err(err).Msg("failed to sign remove-user event")
return
}
if _, err := s.Relay.AddEvent(ctx, removeUser); err != nil {
log.Error().Err(err).Msg("failed to remove user who requested to leave")
return
}
s.Relay.BroadcastEvent(removeUser)
}
}

View File

@ -4,12 +4,13 @@ import (
"fmt"
"log"
"net/http"
"time"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relay29/khatru29"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
func main() {
@ -18,18 +19,18 @@ func main() {
db := &slicestore.SliceStore{}
db.Init()
state := relay29.Init(relay29.Options{
relay, _ := khatru29.Init(relay29.Options{
Domain: "localhost:2929",
DB: db,
SecretKey: relayPrivateKey,
})
// init relay
state.Relay.Info.Name = "very ephemeral chat relay"
state.Relay.Info.Description = "everything will be deleted as soon as I turn off my computer"
relay.Info.Name = "very ephemeral chat relay"
relay.Info.Description = "everything will be deleted as soon as I turn off my computer"
// extra policies
state.Relay.RejectEvent = slices.Insert(state.Relay.RejectEvent, 0,
relay.RejectEvent = append(relay.RejectEvent,
policies.PreventLargeTags(64),
policies.PreventTooManyIndexableTags(6, []int{9005}, nil),
policies.RestrictToSpecifiedKinds(
@ -38,17 +39,17 @@ func main() {
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9021,
),
policies.PreventTimestampsInThePast(60),
policies.PreventTimestampsInTheFuture(30),
policies.PreventTimestampsInThePast(60*time.Second),
policies.PreventTimestampsInTheFuture(30*time.Second),
)
// http routes
state.Relay.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
relay.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "nothing to see here, you must use a nip-29 powered client")
})
fmt.Println("running on http://0.0.0.0:2929")
if err := http.ListenAndServe(":2929", state.Relay); err != nil {
if err := http.ListenAndServe(":2929", relay); err != nil {
log.Fatal("failed to serve")
}
}

View File

@ -0,0 +1,80 @@
package main
import (
"fmt"
"log"
"net/http"
"slices"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relay29/relayer29"
"github.com/fiatjaf/relayer/v2"
"github.com/nbd-wtf/go-nostr"
)
func main() {
relayPrivateKey := nostr.GeneratePrivateKey()
db := &slicestore.SliceStore{}
db.Init()
host := "0.0.0.0"
port := 2929
opts := relay29.Options{
Domain: fmt.Sprintf("%s:%d", host, port),
DB: db,
SecretKey: relayPrivateKey,
}
relay, _ := relayer29.Init(opts)
relay.(*relayer29.Relay).RejectFunc = func(ev *nostr.Event) (bool, string) {
for _, tag := range ev.Tags {
if len(tag) > 1 && len(tag[0]) == 1 {
if len(tag[1]) > 64 {
return true, "event contains too large tags"
}
}
}
if ev.Kind == 9005 {
ntags := 0
for _, tag := range ev.Tags {
if len(tag) > 0 && len(tag[0]) == 1 {
ntags++
if ntags > 6 {
return true, "too many indexable tags"
}
}
}
}
if !slices.Contains([]int{9, 10, 11, 12, 30023, 31922, 31923, 9802, 9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9021}, ev.Kind) {
return true, fmt.Sprintf("received event kind %d not allowed", ev.Kind)
}
if nostr.Now()-ev.CreatedAt > 60 {
return true, "event too old"
}
if ev.CreatedAt-nostr.Now() > 30 {
return true, "event too much in the future"
}
return false, ""
}
server, err := relayer.NewServer(
relay,
relayer.WithPerConnectionLimiter(5.0, 1),
)
if err != nil {
log.Fatalf("failed to create server: %v", err)
}
// http routes
server.Router().HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "nothing to see here, you must use a nip-29 powered client")
})
fmt.Printf("running on http://%v\n", opts.Domain)
if err := server.Start(host, port); err != nil {
log.Fatal("failed to serve")
}
}

View File

@ -34,9 +34,17 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
return
}
question := r.PostFormValue("captcha-id")
solution := r.PostFormValue("captcha-solution")
if !captcha.Verify(question, solution, true) {
log.Info().Str("solution", solution).Msg("invalid captcha")
http.Error(w, "captcha solution is wrong", 400)
return
}
id := make([]byte, 8)
binary.LittleEndian.PutUint64(id, uint64(time.Now().Unix()))
groupId := hex.EncodeToString(id[0:4])
groupId := hex.EncodeToString(id[0:3])
log.Info().Str("id", groupId).Str("owner", pubkey).Msg("making group")
@ -46,10 +54,6 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
return
}
// create group right here
group = state.NewGroup(groupId)
state.Groups.Store(groupId, group)
foundingEvents := []*nostr.Event{
{
CreatedAt: nostr.Now(),
@ -98,6 +102,7 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
}
}
group, _ = state.Groups.Load(groupId)
naddr, _ := nip19.EncodeEntity(s.RelayPubkey, 39000, groupId, []string{"wss://" + s.Domain})
fmt.Fprintf(w, "group created!\n\n%s\naddress: %s", naddr, group.Address)
}

View File

@ -4,10 +4,13 @@ import (
"net/http"
"os"
"slices"
"time"
"github.com/fiatjaf/eventstore/bolt"
"github.com/fiatjaf/eventstore/lmdb"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relay29/khatru29"
"github.com/kelseyhightower/envconfig"
"github.com/nbd-wtf/go-nostr"
"github.com/rs/zerolog"
@ -28,8 +31,9 @@ type Settings struct {
var (
s Settings
db = &bolt.BoltBackend{}
db = &lmdb.LMDBBackend{}
log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger()
relay *khatru.Relay
state *relay29.State
)
@ -50,42 +54,42 @@ func main() {
log.Debug().Str("path", db.Path).Msg("initialized database")
// init relay29 stuff
state = relay29.Init(relay29.Options{
relay, state = khatru29.Init(relay29.Options{
Domain: s.Domain,
DB: db,
SecretKey: s.RelayPrivkey,
})
// init relay
state.Relay.Info.Name = s.RelayName
state.Relay.Info.Description = s.RelayDescription
state.Relay.Info.Contact = s.RelayContact
state.Relay.Info.Icon = s.RelayIcon
relay.Info.Name = s.RelayName
relay.Info.Description = s.RelayDescription
relay.Info.Contact = s.RelayContact
relay.Info.Icon = s.RelayIcon
state.Relay.OverwriteDeletionOutcome = append(state.Relay.OverwriteDeletionOutcome,
relay.OverwriteDeletionOutcome = append(relay.OverwriteDeletionOutcome,
blockDeletesOfOldMessages,
)
state.Relay.RejectEvent = slices.Insert(state.Relay.RejectEvent, 0,
relay.RejectEvent = slices.Insert(relay.RejectEvent, 2,
policies.PreventLargeTags(64),
policies.PreventTooManyIndexableTags(6, []int{9005}, nil),
policies.RestrictToSpecifiedKinds(
9, 10, 11, 12,
30023, 31922, 31923, 9802,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9021,
9021, 9022,
),
policies.PreventTimestampsInThePast(60),
policies.PreventTimestampsInTheFuture(30),
policies.PreventTimestampsInThePast(60*time.Second),
policies.PreventTimestampsInTheFuture(30*time.Second),
rateLimit,
preventGroupCreation,
)
// http routes
state.Relay.Router().HandleFunc("/create", handleCreateGroup)
state.Relay.Router().HandleFunc("/", handleHomepage)
relay.Router().HandleFunc("/create", handleCreateGroup)
relay.Router().HandleFunc("/", handleHomepage)
log.Info().Str("relay-pubkey", s.RelayPubkey).Msg("running on http://0.0.0.0:" + s.Port)
if err := http.ListenAndServe(":"+s.Port, state.Relay); err != nil {
if err := http.ListenAndServe(":"+s.Port, relay); err != nil {
log.Fatal().Err(err).Msg("failed to serve")
}
}

View File

@ -1,10 +1,19 @@
package main
import (
"github.com/mojocn/base64Captcha"
. "github.com/theplant/htmlgo"
)
var captcha = base64Captcha.NewCaptcha(base64Captcha.DefaultDriverDigit, base64Captcha.DefaultMemStore)
func homepageHTML() HTMLComponent {
id, b64s, _, err := captcha.Generate()
if err != nil {
log.Error().Err(err).Msg("failed to draw captcha")
return HTML()
}
return HTML(
Head(
Meta().Charset("utf-8"),
@ -19,6 +28,10 @@ func homepageHTML() HTMLComponent {
Input("").Id("npub").Placeholder("npub1...").Name("pubkey").Class("w-96 px-4 py-2 outline-0 bg-stone-100"),
Label("group name:").For("name").Class("mr-1 mt-4 block"),
Input("").Id("name").Placeholder("my little group").Name("name").Class("w-96 px-4 py-2 outline-0 bg-stone-100"),
Label("solve this:").For("captcha").Class("mr-1 mt-4 block"),
Img(b64s),
Input("").Name("captcha-id").Value(id).Type("hidden"),
Input("").Id("captcha").Placeholder("some number").Name("captcha-solution").Class("w-96 px-4 py-2 outline-0 bg-stone-100"),
Button("create").Class("block rounded mt-4 px-4 py-2 bg-emerald-500 text-white hover:bg-emerald-300 transition-colors"),
).Action("/create").Method("POST"),
).Class("mx-4 my-6"),

View File

@ -4,12 +4,11 @@ import (
"context"
"slices"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
)
func (s *State) requireKindAndSingleGroupIDOrSpecificEventReference(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
func (s *State) RequireKindAndSingleGroupIDOrSpecificEventReference(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
isMeta := false
isNormal := false
isReference := false
@ -36,7 +35,7 @@ func (s *State) requireKindAndSingleGroupIDOrSpecificEventReference(ctx context.
}
}
authed := khatru.GetAuthed(ctx)
authed := s.GetAuthed(ctx)
switch {
case isNormal:
@ -80,30 +79,7 @@ func (s *State) requireKindAndSingleGroupIDOrSpecificEventReference(ctx context.
return true, "invalid query, must have 'h', 'e' or 'a' tag"
}
case isMeta:
// access depends on whether a user is logged in and the groups are public or private
if tags, ok := filter.Tags["d"]; ok && len(tags) > 0 {
// "h" tags specified
for _, tag := range tags {
if group, _ := s.Groups.Load(tag); group != nil {
if !group.Private {
continue // fine, this is public
}
// private,
if authed == "" {
return true, "auth-required: trying to read from a private group"
}
// check membership
group.mu.RLock()
if _, isMember := group.Members[authed]; isMember {
group.mu.RUnlock()
continue // fine, this user is a member
}
group.mu.RUnlock()
return true, "restricted: not a member"
}
}
}
// should be fine
}
return false, ""

18
go.mod
View File

@ -1,19 +1,21 @@
module github.com/fiatjaf/relay29
go 1.22.2
go 1.23.0
require (
github.com/fiatjaf/eventstore v0.7.0
github.com/fiatjaf/khatru v0.6.1
github.com/fiatjaf/eventstore v0.8.2
github.com/fiatjaf/khatru v0.8.1
github.com/fiatjaf/relayer/v2 v2.2.1
github.com/fiatjaf/set v0.0.3
github.com/kelseyhightower/envconfig v1.4.0
github.com/nbd-wtf/go-nostr v0.34.3
github.com/puzpuzpuz/xsync/v3 v3.1.0
github.com/mojocn/base64Captcha v1.3.6
github.com/nbd-wtf/go-nostr v0.35.0
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/rs/zerolog v1.31.0
github.com/stretchr/testify v1.9.0
github.com/theplant/htmlgo v1.0.3
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8
golang.org/x/time v0.4.0
golang.org/x/time v0.5.0
)
require (
@ -29,9 +31,9 @@ require (
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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
@ -43,7 +45,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.54.0 // indirect
go.etcd.io/bbolt v1.3.9 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

64
go.sum
View File

@ -28,7 +28,6 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@ -42,10 +41,12 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fasthttp/websocket v1.5.9 h1:9deGuzYcCRKjk940kNwSN6Hd14hk4zYwropm4UsUIUQ=
github.com/fasthttp/websocket v1.5.9/go.mod h1:NLzHBFur260OMuZHohOfYQwMTpR7sfSpUnuqKxMpgKA=
github.com/fiatjaf/eventstore v0.7.0 h1:etBN0jYodP4VCqhfglIk7VZ+tXAfL1FlDz3c6VygMLE=
github.com/fiatjaf/eventstore v0.7.0/go.mod h1:r5yCFmrVNT2b1xUOuMnDVS3xPGh97y8IgTcLyY2rYP8=
github.com/fiatjaf/khatru v0.6.1 h1:bH9GKhsGY83rTJSYZznXUf7iOFpNKg1zV8vzG0Jrr/4=
github.com/fiatjaf/khatru v0.6.1/go.mod h1:dYTEXVwYQ2tB+whuL9DGg/GLV3y//io/FsQP7qY1jCs=
github.com/fiatjaf/eventstore v0.8.2 h1:nCa3UuJNV5Y5t+SDoPQe7PBmKJ6dhm9TQ/WyR4SCbIM=
github.com/fiatjaf/eventstore v0.8.2/go.mod h1:ck3RxufitHUBjID1RLcRxfX+NMywQzMsdfNpSt6m+9U=
github.com/fiatjaf/khatru v0.8.1 h1:BWAZqwuT0272ZlyzPkuqAA0eGBOs5G3u0Dn1tlWrm6Q=
github.com/fiatjaf/khatru v0.8.1/go.mod h1:jRmqbbIbEH+y0unt3wMUBwqY/btVussqx5SmBoGhXtg=
github.com/fiatjaf/relayer/v2 v2.2.1 h1:BO9Za6kFB2n0aCAv5BCWfIijrSF3dGLZDoCC/8zgb0Y=
github.com/fiatjaf/relayer/v2 v2.2.1/go.mod h1:5HASI/J7g/8h7KAGg1mw7scM3tJy5kggtyrdMyxmLao=
github.com/fiatjaf/set v0.0.3 h1:LoawhcGoD504baBw0TwVJTQemzt15QYuxutzzWl2kgE=
github.com/fiatjaf/set v0.0.3/go.mod h1:hdSwBrO+CwMEbYQAMaHtsib30KQLDtVjbX/1OgDK3tY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -57,6 +58,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/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=
@ -91,8 +94,10 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nbd-wtf/go-nostr v0.34.3 h1:JfDOHOje7gzUhisbZD0v2Y9b9vh2PmP6eHsU/GfU8QE=
github.com/nbd-wtf/go-nostr v0.34.3/go.mod h1:NZQkxl96ggbO8rvDpVjcsojJqKTPwqhP4i82O7K5DJs=
github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw=
github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E=
github.com/nbd-wtf/go-nostr v0.35.0 h1:oINIBr5XE1kowkaz7NXC5vLvj2jUWH6xlzJjChpgV6Q=
github.com/nbd-wtf/go-nostr v0.35.0/go.mod h1:NZQkxl96ggbO8rvDpVjcsojJqKTPwqhP4i82O7K5DJs=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -102,14 +107,13 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/puzpuzpuz/xsync/v3 v3.1.0 h1:EewKT7/LNac5SLiEblJeUu8z5eERHrmRLnMQL2d7qX4=
github.com/puzpuzpuz/xsync/v3 v3.1.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@ -137,23 +141,32 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.54.0 h1:cCL+ZZR3z3HPLMVfEYVUMtJqVaui0+gu7Lx63unHwS0=
github.com/valyala/fasthttp v1.54.0/go.mod h1:6dt4/8olwq9QARP/TDuPmWyWcl4byhpvTJ4AAtcz+QM=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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=
@ -163,17 +176,32 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=

View File

@ -4,10 +4,13 @@ import (
"net/http"
"os"
"slices"
"time"
"github.com/fiatjaf/eventstore/lmdb"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relay29/khatru29"
"github.com/kelseyhightower/envconfig"
"github.com/nbd-wtf/go-nostr"
"github.com/rs/zerolog"
@ -30,6 +33,7 @@ var (
s Settings
db = &lmdb.LMDBBackend{}
log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger()
relay *khatru.Relay
state *relay29.State
)
@ -50,37 +54,36 @@ func main() {
log.Debug().Str("path", db.Path).Msg("initialized database")
// init relay29 stuff
state = relay29.Init(relay29.Options{
relay, state = khatru29.Init(relay29.Options{
Domain: s.Domain,
DB: db,
SecretKey: s.RelayPrivkey,
})
// init relay
state.Relay.Info.Name = s.RelayName
state.Relay.Info.Description = s.RelayDescription
state.Relay.Info.Contact = s.RelayContact
state.Relay.Info.Icon = s.RelayIcon
relay.Info.Name = s.RelayName
relay.Info.Description = s.RelayDescription
relay.Info.Contact = s.RelayContact
relay.Info.Icon = s.RelayIcon
state.Relay.OverwriteDeletionOutcome = append(state.Relay.OverwriteDeletionOutcome,
relay.OverwriteDeletionOutcome = append(relay.OverwriteDeletionOutcome,
blockDeletesOfOldMessages,
)
state.Relay.RejectEvent = slices.Insert(state.Relay.RejectEvent, 2,
relay.RejectEvent = slices.Insert(relay.RejectEvent, 2,
policies.PreventLargeTags(64),
policies.PreventTooManyIndexableTags(6, []int{9005}, nil),
policies.PreventTooManyIndexableTags(6, []int{9000, 9001, 9005}, nil),
policies.RestrictToSpecifiedKinds(
7, 9, 10, 11, 12,
30023, 31922, 31923, 9802,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9021, 9735,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008,
9021, 9022, 9735,
),
policies.PreventTimestampsInThePast(60),
policies.PreventTimestampsInTheFuture(30),
// rateLimit,
policies.PreventTimestampsInThePast(60*time.Second),
policies.PreventTimestampsInTheFuture(30*time.Second),
)
log.Info().Str("relay-pubkey", s.RelayPubkey).Msg("running on http://0.0.0.0:" + s.Port)
if err := http.ListenAndServe(":"+s.Port, state.Relay); err != nil {
if err := http.ListenAndServe(":"+s.Port, relay); err != nil {
log.Fatal().Err(err).Msg("failed to serve")
}
}

70
groups.go Normal file
View File

@ -0,0 +1,70 @@
package relay29
import (
"context"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
)
type Group struct {
nip29.Group
mu sync.RWMutex
}
func (s *State) NewGroup(id string) *Group {
return &Group{
Group: nip29.Group{
Address: nip29.GroupAddress{
ID: id,
Relay: "wss://" + s.Domain,
},
Members: map[string]*nip29.Role{},
},
}
}
// loadGroups loads all the group metadata from all the past action messages.
func (s *State) loadGroups(ctx context.Context) {
groupMetadataEvents, _ := s.DB.QueryEvents(ctx, nostr.Filter{Kinds: []int{nostr.KindSimpleGroupCreateGroup}})
for evt := range groupMetadataEvents {
gtag := evt.Tags.GetFirst([]string{"h", ""})
id := (*gtag)[1]
group := s.NewGroup(id)
f := nostr.Filter{
Limit: 5000, Kinds: nip29.ModerationEventKinds, Tags: nostr.TagMap{"h": []string{id}},
}
ch, _ := s.DB.QueryEvents(ctx, f)
events := make([]*nostr.Event, 0, 5000)
for event := range ch {
events = append(events, event)
}
for i := len(events) - 1; i >= 0; i-- {
evt := events[i]
act, _ := PrepareModerationAction(evt)
act.Apply(&group.Group)
}
// if the group was deleted there will be no actions after the delete
if len(events) > 0 && events[0].Kind == nostr.KindSimpleGroupDeleteGroup {
// we don't keep track of this if it was deleted
continue
}
s.Groups.Store(group.Address.ID, group)
}
}
func (s *State) GetGroupFromEvent(event *nostr.Event) *Group {
group, _ := s.Groups.Load(GetGroupIDFromEvent(event))
return group
}
func GetGroupIDFromEvent(event *nostr.Event) string {
gtag := event.Tags.GetFirst([]string{"h", ""})
groupId := (*gtag)[1]
return groupId
}

53
khatru29/khatru29.go Normal file
View File

@ -0,0 +1,53 @@
package khatru29
import (
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
)
func Init(opts relay29.Options) (*khatru.Relay, *relay29.State) {
pubkey, _ := nostr.GetPublicKey(opts.SecretKey)
// create a new relay29.State
state := relay29.New(opts)
// create a new khatru relay
relay := khatru.NewRelay()
relay.Info.PubKey = pubkey
relay.Info.SupportedNIPs = append(relay.Info.SupportedNIPs, 29)
// assign khatru relay to relay29.State
state.Relay = relay
// provide GetAuthed function
state.GetAuthed = khatru.GetAuthed
// apply basic relay policies
relay.StoreEvent = append(relay.StoreEvent, state.DB.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents,
state.NormalEventQuery,
state.MetadataQueryHandler,
state.AdminsQueryHandler,
state.MembersQueryHandler,
)
relay.DeleteEvent = append(relay.DeleteEvent, state.DB.DeleteEvent)
relay.RejectFilter = append(relay.RejectFilter,
state.RequireKindAndSingleGroupIDOrSpecificEventReference,
)
relay.RejectEvent = append(relay.RejectEvent,
state.RequireHTagForExistingGroup,
state.RequireModerationEventsToBeRecent,
state.RestrictWritesBasedOnGroupRules,
state.RestrictInvalidModerationActions,
state.PreventWritingOfEventsJustDeleted,
)
relay.OnEventSaved = append(relay.OnEventSaved,
state.ApplyModerationAction,
state.ReactToJoinRequest,
state.ReactToLeaveRequest,
)
relay.OnConnect = append(relay.OnConnect, khatru.RequestAuth)
return relay, state
}

468
khatru29/relay_test.go Normal file
View File

@ -0,0 +1,468 @@
package khatru29
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
)
var relayPrivateKey = nostr.GeneratePrivateKey()
func startTestRelay() func() {
db := &slicestore.SliceStore{}
db.Init()
relay, state := Init(relay29.Options{
Domain: "localhost:29292",
DB: db,
SecretKey: relayPrivateKey,
})
relay.Info.Name = "very testy relay"
relay.Info.Description = "this is just for testing"
// don't do this at home -- we're going to remove one requirement to make tests simpler
relay.RejectEvent = slices.DeleteFunc(relay.RejectEvent, func(f func(ctx context.Context, event *nostr.Event) (reject bool, msg string)) bool {
return fmt.Sprintf("%v", []any{f}) == fmt.Sprintf("%v", []any{state.RequireModerationEventsToBeRecent})
})
server := &http.Server{Addr: ":29292", Handler: relay}
go func() {
server.ListenAndServe()
}()
return func() {
server.Shutdown(context.Background())
}
}
func TestGroupStuffABunch(t *testing.T) {
defer startTestRelay()()
ctx := context.Background()
user1 := "0000000000000000000000000000000000000000000000000000000000000001"
user1pk, _ := nostr.GetPublicKey(user1)
user2 := "0000000000000000000000000000000000000000000000000000000000000002"
user2pk, _ := nostr.GetPublicKey(user2)
user3 := "0000000000000000000000000000000000000000000000000000000000000003"
user3pk, _ := nostr.GetPublicKey(user3)
// simple open group
{
r, err := nostr.RelayConnect(ctx, "ws://localhost:29292")
require.NoError(t, err, "failed to connect to relay")
metaSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"a"}}}})
require.NoError(t, err, "failed to subscribe to group metadata")
membersSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39002}, Tags: nostr.TagMap{"d": []string{"a"}}}})
require.NoError(t, err, "failed to subscribe to group members")
// create group
createGroup := nostr.Event{
CreatedAt: 1,
Kind: 9007,
Tags: nostr.Tags{{"h", "a"}},
}
createGroup.Sign(user1)
require.NoError(t, r.Publish(ctx, createGroup), "failed to publish kind 9007")
// see if we get notified about that
select {
case evt := <-metaSub.Events:
require.Equal(t, "a", evt.Tags.GetD())
require.Nil(t, evt.Tags.GetFirst([]string{"private"}))
require.NotNil(t, evt.Tags.GetFirst([]string{"public"}))
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
select {
case evt := <-membersSub.Events:
require.Equal(t, "a", evt.Tags.GetD())
require.NotNil(t,
evt.Tags.GetFirst([]string{"p", user1pk}),
)
require.Len(t, evt.Tags, 2)
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
// invite another member
inviteMember := nostr.Event{
CreatedAt: 2,
Kind: 9000,
Tags: nostr.Tags{{"h", "a"}, {"p", user2pk}},
}
inviteMember.Sign(user1)
require.NoError(t, r.Publish(ctx, inviteMember), "failed to publish kind 9000")
// see if we get notified about that
select {
case evt := <-membersSub.Events:
require.Equal(t, "a", evt.Tags.GetD())
require.NotNil(t,
evt.Tags.GetFirst([]string{"p", user1pk}),
evt.Tags.GetFirst([]string{"p", user2pk}),
)
require.Len(t, evt.Tags, 3)
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
// update metadata
updateMetadata := nostr.Event{
CreatedAt: 3,
Kind: 9002,
Tags: nostr.Tags{{"h", "a"}, {"name", "alface"}},
}
updateMetadata.Sign(user1)
require.NoError(t, r.Publish(ctx, updateMetadata), "failed to publish kind 9002")
// see if we get notified about that
select {
case evt := <-metaSub.Events:
require.Equal(t, "a", evt.Tags.GetD())
require.Equal(t, &nostr.Tag{"name", "alface"}, evt.Tags.GetFirst([]string{"name"}))
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
msgSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{9, 10}, Tags: nostr.TagMap{"h": []string{"a"}}}})
require.NoError(t, err, "failed to subscribe to group messages")
// publish some messages
for i := 4; i < 10; i++ {
message := nostr.Event{
CreatedAt: nostr.Timestamp(i),
Content: fmt.Sprintf("hello %d", i),
Kind: 9,
Tags: nostr.Tags{{"h", "a"}},
}
signer := user1
if i%2 == 1 {
signer = user2
}
message.Sign(signer)
require.NoError(t, r.Publish(ctx, message), "failed to publish kind 9")
}
// check if we have received messages correctly from the subscription
for i := 4; i < 10; i++ {
publisher := user1pk
if i%2 == 1 {
publisher = user2pk
}
message := <-msgSub.Events
require.Equal(t, fmt.Sprintf("hello %d", i), message.Content)
require.Equal(t, publisher, message.PubKey)
}
// events that should be rejected
failedNoHTag := nostr.Event{
CreatedAt: 11,
Content: "failed",
Kind: 9,
}
failedNoHTag.Sign(user1)
require.Error(t, r.Publish(ctx, failedNoHTag), "should fail to publish kind 9 with no h tag")
failedWrongHTag := nostr.Event{
CreatedAt: 11,
Content: "failed",
Kind: 9,
Tags: nostr.Tags{{"h", "b"}},
}
failedWrongHTag.Sign(user1)
require.Error(t, r.Publish(ctx, failedWrongHTag), "should fail to publish kind 9 with wrong h tag")
failedFromNonMember := nostr.Event{
CreatedAt: 11,
Content: "failed",
Kind: 9,
Tags: nostr.Tags{{"h", "a"}},
}
failedWrongHTag.Sign(user3)
require.Error(t, r.Publish(ctx, failedFromNonMember), "should fail to publish kind 9 from non-member")
// get stored messages
ext, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{9, 10, 11, 12}, Tags: nostr.TagMap{"h": []string{"a"}}}})
require.NoError(t, err, "failed to subscribe to messages again")
count := 0
for {
select {
case message := <-ext.Events:
require.Equal(t, 9, message.Kind)
require.Equal(t, fmt.Sprintf("hello %d", message.CreatedAt), message.Content)
count++
case <-ext.EndOfStoredEvents:
require.Equal(t, 6, count, "must have 6 messages")
goto end1_1
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
}
end1_1:
}
// adding now a private group
{
r, err := nostr.RelayConnect(ctx, "ws://localhost:29292")
require.NoError(t, err, "failed to connect to relay")
createGroupFail := nostr.Event{
CreatedAt: 1,
Kind: 9007,
Tags: nostr.Tags{{"h", "a"}},
}
createGroupFail.Sign(user3)
require.Error(t, r.Publish(ctx, createGroupFail), "should fail to publish kind 9007 for existing group")
metaSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"b"}}}})
require.NoError(t, err, "failed to subscribe to group metadata")
membersSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39002}, Tags: nostr.TagMap{"d": []string{"b"}}}})
require.NoError(t, err, "failed to subscribe to group members")
createGroup := nostr.Event{
CreatedAt: 1,
Kind: 9007,
Tags: nostr.Tags{{"h", "b"}},
}
createGroup.Sign(user3)
require.NoError(t, r.Publish(ctx, createGroup), "failed to publish kind 9007")
select {
case evt := <-metaSub.Events:
require.Equal(t, "b", evt.Tags.GetD())
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
select {
case evt := <-membersSub.Events:
require.Equal(t, "b", evt.Tags.GetD())
require.NotNil(t,
evt.Tags.GetFirst([]string{"p", user3pk}),
)
require.Len(t, evt.Tags, 2)
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
inviteMember := nostr.Event{
CreatedAt: 2,
Kind: 9000,
Tags: nostr.Tags{{"h", "b"}, {"p", user2pk}},
}
inviteMember.Sign(user3)
require.NoError(t, r.Publish(ctx, inviteMember), "failed to publish kind 9000")
select {
case evt := <-membersSub.Events:
require.Equal(t, "b", evt.Tags.GetD())
require.NotNil(t,
evt.Tags.GetFirst([]string{"p", user3pk}),
evt.Tags.GetFirst([]string{"p", user2pk}),
)
require.Len(t, evt.Tags, 3)
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
setGroupPrivate := nostr.Event{
CreatedAt: 3,
Kind: 9006,
Tags: nostr.Tags{
{"h", "b"},
{"private"},
},
}
setGroupPrivate.Sign(user2)
require.Error(t, r.Publish(ctx, setGroupPrivate), "should fail to accept moderation from non-mod")
setGroupPrivate.Sign(user3)
require.NoError(t, r.Publish(ctx, setGroupPrivate), "failed to publish kind 9006")
select {
case evt := <-metaSub.Events:
require.Equal(t, "b", evt.Tags.GetD())
require.Nil(t, evt.Tags.GetFirst([]string{"public"}))
require.NotNil(t, evt.Tags.GetFirst([]string{"private"}))
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
for i := 4; i < 10; i++ {
message := nostr.Event{
CreatedAt: nostr.Timestamp(i),
Content: fmt.Sprintf("hello %d", i),
Kind: 9,
Tags: nostr.Tags{{"h", "b"}},
}
signer := user3
if i%2 == 1 {
signer = user2
}
message.Sign(signer)
require.NoError(t, r.Publish(ctx, message), "failed to publish kind 9")
}
failedSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{9}, Tags: nostr.TagMap{"h": []string{"b"}}}})
require.NoError(t, err, "failed to subscribe to private messages")
for {
select {
case <-failedSub.Events:
t.Fatal("should not have received events")
return
case <-failedSub.EndOfStoredEvents:
t.Fatal("should not have received EOSE")
return
case closed := <-failedSub.ClosedReason:
require.Contains(t, closed, "auth-required:")
goto end2_1
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
}
end2_1:
r2, err := nostr.RelayConnect(ctx, "ws://localhost:29292")
require.NoError(t, err, "failed to connect to relay")
time.Sleep(time.Millisecond * 20) // wait until auth is received
err = r2.Auth(ctx, func(authEvent *nostr.Event) error {
authEvent.Sign(user2)
return nil
})
require.NoError(t, err, "auth should have worked")
goodSub, err := r2.Subscribe(ctx, nostr.Filters{{Kinds: []int{9}, Tags: nostr.TagMap{"h": []string{"b"}}}})
require.NoError(t, err, "failed to subscribe to private messages")
count := 0
for {
select {
case message := <-goodSub.Events:
require.Equal(t, 9, message.Kind)
require.Equal(t, fmt.Sprintf("hello %d", message.CreatedAt), message.Content)
count++
case <-goodSub.EndOfStoredEvents:
require.Equal(t, 6, count, "must have 6 messages")
goto end2_2
case <-failedSub.ClosedReason:
t.Fatal("should not have received CLOSED")
case <-time.After(time.Second):
t.Fatal("select took too long")
return
}
}
end2_2:
anotherMessage := nostr.Event{
CreatedAt: 11,
Content: "last",
Kind: 9,
Tags: nostr.Tags{{"h", "b"}},
}
anotherMessage.Sign(user3)
require.NoError(t, r.Publish(ctx, anotherMessage), "failed to publish last kind 9")
select {
case message := <-goodSub.Events:
// good sub should receive it
require.Equal(t, "last", message.Content)
case <-time.After(time.Millisecond):
t.Fatal("select took too long")
return
}
select {
case <-failedSub.Events:
t.Fatal("unauthed sub should not receive it")
case <-time.After(time.Millisecond * 200):
}
}
{
// query members list filtering by "#p"
for i, s := range []struct {
key string
groupcount int
groupcountwhenauthedasuser2 int
}{
{user1pk, 1, 1}, {user2pk, 1, 2}, {user3pk, 0, 1},
} {
r, err := nostr.RelayConnect(ctx, "ws://localhost:29292")
require.NoError(t, err, "failed to connect to relay")
ms, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39002}, Tags: nostr.TagMap{"p": []string{s.key}}}})
require.NoError(t, err, "failed to subscribe to group members")
count := 0
for {
select {
case message := <-ms.Events:
require.Equal(t, 39002, message.Kind)
count++
case <-ms.EndOfStoredEvents:
require.Equal(t, s.groupcount, count,
"when unauthed for key%d expected %d groups but got %d",
i+1, s.groupcount, count)
goto end3_1
case <-time.After(time.Second):
t.Fatalf("select took too long for key%d", i+1)
return
}
}
end3_1:
// perform auth and try again
err = r.Auth(ctx, func(authEvent *nostr.Event) error {
authEvent.Sign(user2)
return nil
})
ms, err = r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39002}, Tags: nostr.TagMap{"p": []string{s.key}}}})
require.NoError(t, err, "failed to subscribe to group members")
count = 0
for {
select {
case message := <-ms.Events:
require.Equal(t, 39002, message.Kind)
count++
case <-ms.EndOfStoredEvents:
require.Equal(t, s.groupcountwhenauthedasuser2, count,
"when authed for key%d expected %d groups but got %d",
i+1, s.groupcountwhenauthedasuser2, count)
goto end3_2
case <-time.After(time.Second):
return
}
}
end3_2:
}
}
}

352
moderation_actions.go Normal file
View File

@ -0,0 +1,352 @@
package relay29
import (
"fmt"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
)
var PTagNotValidPublicKey = fmt.Errorf("'p' tag value is not a valid public key")
type Action interface {
Apply(group *nip29.Group)
PermissionName() nip29.Permission
}
func PrepareModerationAction(evt *nostr.Event) (Action, error) {
factory, ok := moderationActionFactories[evt.Kind]
if !ok {
return nil, fmt.Errorf("event kind %d is not a supported moderation action", evt.Kind)
}
return factory(evt)
}
var moderationActionFactories = map[int]func(*nostr.Event) (Action, error){
nostr.KindSimpleGroupAddUser: func(evt *nostr.Event) (Action, error) {
targets := make([]string, 0, len(evt.Tags))
for _, tag := range evt.Tags.GetAll([]string{"p", ""}) {
if !nostr.IsValidPublicKey(tag[1]) {
return nil, PTagNotValidPublicKey
}
targets = append(targets, tag[1])
}
if len(targets) > 0 {
return &AddUser{Targets: targets, When: evt.CreatedAt}, nil
}
return nil, fmt.Errorf("missing 'p' tags")
},
nostr.KindSimpleGroupRemoveUser: func(evt *nostr.Event) (Action, error) {
targets := make([]string, 0, len(evt.Tags))
for _, tag := range evt.Tags.GetAll([]string{"p", ""}) {
if !nostr.IsValidPublicKey(tag[1]) {
return nil, PTagNotValidPublicKey
}
targets = append(targets, tag[1])
}
if len(targets) > 0 {
return &RemoveUser{Targets: targets, When: evt.CreatedAt}, nil
}
return nil, fmt.Errorf("missing 'p' tags")
},
nostr.KindSimpleGroupEditMetadata: func(evt *nostr.Event) (Action, error) {
ok := false
edit := EditMetadata{When: evt.CreatedAt}
if t := evt.Tags.GetFirst([]string{"name", ""}); t != nil {
edit.NameValue = (*t)[1]
ok = true
}
if t := evt.Tags.GetFirst([]string{"picture", ""}); t != nil {
edit.PictureValue = (*t)[1]
ok = true
}
if t := evt.Tags.GetFirst([]string{"about", ""}); t != nil {
edit.AboutValue = (*t)[1]
ok = true
}
if ok {
return &edit, nil
}
return nil, fmt.Errorf("missing metadata tags")
},
nostr.KindSimpleGroupAddPermission: func(evt *nostr.Event) (Action, error) {
nTags := len(evt.Tags)
permissions := make([]nip29.Permission, 0, nTags-1)
for _, tag := range evt.Tags.GetAll([]string{"permission", ""}) {
perm := nip29.Permission(tag[1])
if _, ok := nip29.PermissionsMap[perm]; !ok {
return nil, fmt.Errorf("unknown permission '%s'", tag[1])
}
permissions = append(permissions, perm)
}
targets := make([]string, 0, nTags-1)
for _, tag := range evt.Tags.GetAll([]string{"p", ""}) {
if !nostr.IsValidPublicKey(tag[1]) {
return nil, PTagNotValidPublicKey
}
targets = append(targets, tag[1])
}
if len(permissions) > 0 && len(targets) > 0 {
return &AddPermission{Initiator: evt.PubKey, Targets: targets, Permissions: permissions, When: evt.CreatedAt}, nil
}
return nil, fmt.Errorf("")
},
nostr.KindSimpleGroupRemovePermission: func(evt *nostr.Event) (Action, error) {
nTags := len(evt.Tags)
permissions := make([]nip29.Permission, 0, nTags-1)
for _, tag := range evt.Tags.GetAll([]string{"permission", ""}) {
perm := nip29.Permission(tag[1])
if _, ok := nip29.PermissionsMap[perm]; !ok {
return nil, fmt.Errorf("unknown permission '%s'", tag[1])
}
permissions = append(permissions, perm)
}
targets := make([]string, 0, nTags-1)
for _, tag := range evt.Tags.GetAll([]string{"p", ""}) {
if !nostr.IsValidPublicKey(tag[1]) {
return nil, PTagNotValidPublicKey
}
targets = append(targets, tag[1])
}
if len(permissions) > 0 && len(targets) > 0 {
return &RemovePermission{Targets: targets, Permissions: permissions, When: evt.CreatedAt}, nil
}
return nil, fmt.Errorf("")
},
nostr.KindSimpleGroupDeleteEvent: func(evt *nostr.Event) (Action, error) {
tags := evt.Tags.GetAll([]string{"e", ""})
if len(tags) == 0 {
return nil, fmt.Errorf("missing 'e' tag")
}
targets := make([]string, len(tags))
for i, tag := range tags {
if nostr.IsValid32ByteHex(tag[1]) {
targets[i] = tag[1]
} else {
return nil, fmt.Errorf("invalid event id hex")
}
}
return &DeleteEvent{Targets: targets}, nil
},
nostr.KindSimpleGroupEditGroupStatus: func(evt *nostr.Event) (Action, error) {
egs := EditGroupStatus{When: evt.CreatedAt}
egs.Public = evt.Tags.GetFirst([]string{"public"}) != nil
egs.Private = evt.Tags.GetFirst([]string{"private"}) != nil
egs.Open = evt.Tags.GetFirst([]string{"open"}) != nil
egs.Closed = evt.Tags.GetFirst([]string{"closed"}) != nil
// disallow contradictions
if egs.Public && egs.Private {
return nil, fmt.Errorf("contradiction: can't be public and private at the same time")
}
if egs.Open && egs.Closed {
return nil, fmt.Errorf("contradiction: can't be open and closed at the same time")
}
return egs, nil
},
nostr.KindSimpleGroupCreateGroup: func(evt *nostr.Event) (Action, error) {
return &CreateGroup{Creator: evt.PubKey, When: evt.CreatedAt}, nil
},
nostr.KindSimpleGroupDeleteGroup: func(evt *nostr.Event) (Action, error) {
return &CreateGroup{When: evt.CreatedAt}, nil
},
}
type DeleteEvent struct {
Targets []string
}
func (DeleteEvent) PermissionName() nip29.Permission { return nip29.PermDeleteEvent }
func (a DeleteEvent) Apply(group *nip29.Group) {}
type AddUser struct {
Targets []string
When nostr.Timestamp
}
func (AddUser) PermissionName() nip29.Permission { return nip29.PermAddUser }
func (a AddUser) Apply(group *nip29.Group) {
for _, target := range a.Targets {
group.Members[target] = nip29.EmptyRole
}
}
type RemoveUser struct {
Targets []string
When nostr.Timestamp
}
func (RemoveUser) PermissionName() nip29.Permission { return nip29.PermRemoveUser }
func (a RemoveUser) Apply(group *nip29.Group) {
for _, tpk := range a.Targets {
if target, ok := group.Members[tpk]; ok {
if target != nip29.EmptyRole {
_, hasSuperiorOrEqualPermission := target.Permissions[nip29.PermRemoveUser]
if hasSuperiorOrEqualPermission {
continue
}
}
delete(group.Members, tpk)
}
}
}
type EditMetadata struct {
NameValue string
PictureValue string
AboutValue string
When nostr.Timestamp
}
func (EditMetadata) PermissionName() nip29.Permission { return nip29.PermEditMetadata }
func (a EditMetadata) Apply(group *nip29.Group) {
group.Name = a.NameValue
group.Picture = a.PictureValue
group.About = a.AboutValue
group.LastMetadataUpdate = a.When
}
type AddPermission struct {
Initiator string // the user who is adding the permissions
Targets []string
Permissions []nip29.Permission
When nostr.Timestamp
}
func (AddPermission) PermissionName() nip29.Permission { return nip29.PermAddPermission }
func (a AddPermission) Apply(group *nip29.Group) {
for _, tpk := range a.Targets {
role, ok := group.Members[tpk]
// if it's a normal user, create a new role object thing for this user
// instead of modifying the global EmptyRole
if !ok || role == nip29.EmptyRole {
role = &nip29.Role{Permissions: make(map[nip29.Permission]struct{})}
group.Members[tpk] = role
// when the user doesn't exit it will be added, so
group.LastMembersUpdate = a.When
}
// only add role that the user performing this already have
initiator, ok := group.Members[a.Initiator]
if ok {
for _, perm := range a.Permissions {
if _, has := initiator.Permissions[perm]; has {
role.Permissions[perm] = struct{}{}
}
}
}
}
group.LastAdminsUpdate = a.When
}
type RemovePermission struct {
Targets []string
Permissions []nip29.Permission
When nostr.Timestamp
}
func (RemovePermission) PermissionName() nip29.Permission { return nip29.PermRemovePermission }
func (a RemovePermission) Apply(group *nip29.Group) {
for _, tpk := range a.Targets {
target, ok := group.Members[tpk]
if !ok || target == nip29.EmptyRole {
continue
}
_, hasSuperiorOrEqualPermission := target.Permissions[nip29.PermRemovePermission]
if hasSuperiorOrEqualPermission {
continue
}
// remove all permissions listed
for _, perm := range a.Permissions {
delete(target.Permissions, perm)
}
// if no more permissions are available, change this guy to be a normal user
if target.Name == "" && len(target.Permissions) == 0 {
group.Members[tpk] = nip29.EmptyRole
}
}
group.LastAdminsUpdate = a.When
}
type EditGroupStatus struct {
Public bool
Private bool
Open bool
Closed bool
When nostr.Timestamp
}
func (EditGroupStatus) PermissionName() nip29.Permission { return nip29.PermEditGroupStatus }
func (a EditGroupStatus) Apply(group *nip29.Group) {
if a.Public {
group.Private = false
} else if a.Private {
group.Private = true
}
if a.Open {
group.Closed = false
} else if a.Closed {
group.Closed = true
}
group.LastMetadataUpdate = a.When
}
type CreateGroup struct {
Creator string
When nostr.Timestamp
}
func (CreateGroup) PermissionName() nip29.Permission { return nip29.PermEditGroupStatus }
func (a CreateGroup) Apply(group *nip29.Group) {
group.Members[a.Creator] = &nip29.Role{
Permissions: map[nip29.Permission]struct{}{
nip29.PermAddUser: {},
nip29.PermRemoveUser: {},
nip29.PermEditMetadata: {},
nip29.PermAddPermission: {},
nip29.PermRemovePermission: {},
nip29.PermDeleteEvent: {},
nip29.PermEditGroupStatus: {},
nip29.PermDeleteGroupStatus: {},
},
}
group.LastMetadataUpdate = a.When
group.LastAdminsUpdate = a.When
group.LastMembersUpdate = a.When
}
type DeleteGroup struct {
When nostr.Timestamp
}
func (DeleteGroup) PermissionName() nip29.Permission { return nip29.PermDeleteGroupStatus }
func (a DeleteGroup) Apply(group *nip29.Group) {
group.Members = make(map[string]*nip29.Role)
group.Closed = true
group.Private = true
group.Name = "[deleted]"
group.About = ""
group.Picture = ""
group.LastMetadataUpdate = a.When
group.LastAdminsUpdate = a.When
group.LastMembersUpdate = a.When
}

View File

@ -3,17 +3,16 @@ package relay29
import (
"context"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/set"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
"golang.org/x/exp/slices"
)
func (s *State) metadataQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
func (s *State) MetadataQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
ch := make(chan *nostr.Event, 1)
authed := khatru.GetAuthed(ctx)
authed := s.GetAuthed(ctx)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupMetadata) {
if _, ok := filter.Tags["d"]; !ok {
@ -32,7 +31,7 @@ func (s *State) metadataQueryHandler(ctx context.Context, filter nostr.Filter) (
}
evt := group.ToMetadataEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
return true
})
@ -40,7 +39,7 @@ func (s *State) metadataQueryHandler(ctx context.Context, filter nostr.Filter) (
for _, groupId := range filter.Tags["d"] {
if group, _ := s.Groups.Load(groupId); group != nil {
evt := group.ToMetadataEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
}
}
@ -53,10 +52,10 @@ func (s *State) metadataQueryHandler(ctx context.Context, filter nostr.Filter) (
return ch, nil
}
func (s *State) adminsQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
func (s *State) AdminsQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
ch := make(chan *nostr.Event, 1)
authed := khatru.GetAuthed(ctx)
authed := s.GetAuthed(ctx)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupAdmins) {
if _, ok := filter.Tags["d"]; !ok {
@ -76,7 +75,7 @@ func (s *State) adminsQueryHandler(ctx context.Context, filter nostr.Filter) (ch
return true
}
evt := group.ToAdminsEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
return true
})
@ -97,7 +96,7 @@ func (s *State) adminsQueryHandler(ctx context.Context, filter nostr.Filter) (ch
continue
}
evt := group.ToAdminsEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
}
}
@ -110,10 +109,10 @@ func (s *State) adminsQueryHandler(ctx context.Context, filter nostr.Filter) (ch
return ch, nil
}
func (s *State) membersQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
func (s *State) MembersQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
ch := make(chan *nostr.Event, 1)
authed := khatru.GetAuthed(ctx)
authed := s.GetAuthed(ctx)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupMembers) {
if _, ok := filter.Tags["d"]; !ok {
@ -133,7 +132,7 @@ func (s *State) membersQueryHandler(ctx context.Context, filter nostr.Filter) (c
return true
}
evt := group.ToMembersEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
return true
})
@ -154,7 +153,7 @@ func (s *State) membersQueryHandler(ctx context.Context, filter nostr.Filter) (c
continue
}
evt := group.ToMembersEvent()
evt.Sign(s.privateKey)
evt.Sign(s.secretKey)
ch <- evt
}
}
@ -167,14 +166,14 @@ func (s *State) membersQueryHandler(ctx context.Context, filter nostr.Filter) (c
return ch, nil
}
func (s *State) normalEventQuery(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
func (s *State) NormalEventQuery(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
if hTags, hasHTags := filter.Tags["h"]; hasHTags && len(hTags) > 0 {
// if these tags are present we already know access is safe because we've verified that in filter_policy.go
return s.DB.QueryEvents(ctx, filter)
}
ch := make(chan *nostr.Event)
authed := khatru.GetAuthed(ctx)
authed := s.GetAuthed(ctx)
go func() {
// now here in refE/refA/ids we have to check for each result if it is allowed
var results chan *nostr.Event

160
relayer29/relayer.go Normal file
View File

@ -0,0 +1,160 @@
package relayer29
import (
"context"
"errors"
"github.com/fiatjaf/eventstore"
"github.com/fiatjaf/relay29"
"github.com/fiatjaf/relayer/v2"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
)
type Relay struct {
NIP11Info func() nip11.RelayInformationDocument
RejectFunc func(*nostr.Event) (bool, string)
pubkey string
opts relay29.Options
state *relay29.State
}
func (r *Relay) Name() string {
return "nostr-relay29"
}
func (r *Relay) Init() error {
return nil
}
func (r *Relay) Storage(ctx context.Context) eventstore.Store {
return &Store{
relay: r,
state: r.state,
store: r.opts.DB,
}
}
func (r *Relay) AcceptEvent(ctx context.Context, ev *nostr.Event) bool {
return true
}
func (r *Relay) GetNIP11InformationDocument() nip11.RelayInformationDocument {
if r.NIP11Info != nil {
return r.NIP11Info()
}
return nip11.RelayInformationDocument{
Name: "nostr-relay29",
Description: "relay29 rleay powered by the relayer framework",
SupportedNIPs: []int{29},
}
}
func (r *Relay) BroadcastEvent(ev *nostr.Event) {
relayer.BroadcastEvent(ev)
}
func (r *Relay) AddEvent(ctx context.Context, ev *nostr.Event) (skipBroadcast bool, writeError error) {
err := r.opts.DB.SaveEvent(ctx, ev)
return true, err
}
func Init(opts relay29.Options) (relayer.Relay, *relay29.State) {
pubkey, _ := nostr.GetPublicKey(opts.SecretKey)
// create a new relay29.State
state := relay29.New(opts)
// create a new khatru relay
relay := &Relay{
pubkey: pubkey,
state: state,
opts: opts,
}
// assign khatru relay to relay29.State
state.Relay = relay
// provide GetAuthed function
state.GetAuthed = func(context.Context) string {
// TODO
return ""
}
return relay, state
}
type Store struct {
relay *Relay
state *relay29.State
store eventstore.Store
}
func (s *Store) Init() error {
return s.store.Init()
}
func (s *Store) Close() {
s.store.Close()
}
func (s *Store) QueryEvents(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
if rejected, msg := s.state.RequireKindAndSingleGroupIDOrSpecificEventReference(ctx, filter); rejected {
return nil, errors.New(msg)
}
rfs := []func(context.Context, nostr.Filter) (chan *nostr.Event, error){
s.state.NormalEventQuery,
s.state.MetadataQueryHandler,
s.state.AdminsQueryHandler,
s.state.MembersQueryHandler,
}
for _, rf := range rfs {
if evc, err := rf(ctx, filter); err == nil {
return evc, nil
}
}
return s.store.QueryEvents(ctx, filter)
}
func (s *Store) DeleteEvent(ctx context.Context, ev *nostr.Event) error {
return s.store.DeleteEvent(ctx, ev)
}
func (s *Store) SaveEvent(ctx context.Context, ev *nostr.Event) error {
if s.relay.RejectFunc != nil {
if rejected, msg := s.relay.RejectFunc(ev); rejected {
return errors.New(msg)
}
}
bfs := []func(context.Context, *nostr.Event) (bool, string){
s.state.RequireHTagForExistingGroup,
s.state.RequireModerationEventsToBeRecent,
s.state.RestrictWritesBasedOnGroupRules,
s.state.RestrictInvalidModerationActions,
s.state.PreventWritingOfEventsJustDeleted,
}
for _, rf := range bfs {
if rejected, msg := rf(ctx, ev); rejected {
return errors.New(msg)
}
}
err := s.store.SaveEvent(ctx, ev)
if err != nil {
return err
}
afs := []func(context.Context, *nostr.Event){
s.state.ApplyModerationAction,
s.state.ReactToJoinRequest,
s.state.ReactToLeaveRequest,
}
for _, rf := range afs {
rf(ctx, ev)
}
return err
}

139
state.go
View File

@ -2,26 +2,28 @@ package relay29
import (
"context"
"sync"
"github.com/fiatjaf/eventstore"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/set"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
nip29_relay "github.com/nbd-wtf/go-nostr/nip29/relay"
"github.com/puzpuzpuz/xsync/v3"
)
type State struct {
Relay *khatru.Relay
Domain string
Groups *xsync.MapOf[string, *Group]
DB eventstore.Store
Relay interface {
BroadcastEvent(*nostr.Event)
AddEvent(context.Context, *nostr.Event) (skipBroadcast bool, writeError error)
}
GetAuthed func(context.Context) string
AllowPrivateGroups bool
deletedCache set.Set[string]
publicKey string
privateKey string
secretKey string
}
type Options struct {
@ -30,7 +32,7 @@ type Options struct {
SecretKey string
}
func Init(opts Options) *State {
func New(opts Options) *State {
pubkey, _ := nostr.GetPublicKey(opts.SecretKey)
// events that just got deleted will be cached here for `tooOld` seconds such that someone doesn't rebroadcast
@ -40,125 +42,20 @@ func Init(opts Options) *State {
// we keep basic data about all groups in memory
groups := xsync.NewMapOf[string, *Group]()
// we create a new khatru relay
relay := khatru.NewRelay()
relay.Info.PubKey = pubkey
relay.Info.SupportedNIPs = append(relay.Info.SupportedNIPs, 29)
state := &State{
relay,
opts.Domain,
groups,
opts.DB,
deletedCache,
pubkey,
opts.SecretKey,
Domain: opts.Domain,
Groups: groups,
DB: opts.DB,
AllowPrivateGroups: true,
deletedCache: deletedCache,
publicKey: pubkey,
secretKey: opts.SecretKey,
}
// load all groups
state.loadGroups(context.Background())
// apply basic relay policies
relay.StoreEvent = append(relay.StoreEvent, state.DB.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents,
state.normalEventQuery,
state.metadataQueryHandler,
state.adminsQueryHandler,
state.membersQueryHandler,
)
relay.DeleteEvent = append(relay.DeleteEvent, state.DB.DeleteEvent)
relay.RejectFilter = append(relay.RejectFilter,
state.requireKindAndSingleGroupIDOrSpecificEventReference,
)
relay.RejectEvent = append(relay.RejectEvent,
state.requireHTagForExistingGroup,
state.requireModerationEventsToBeRecent,
state.restrictWritesBasedOnGroupRules,
state.restrictInvalidModerationActions,
state.preventWritingOfEventsJustDeleted,
)
relay.OnEventSaved = append(relay.OnEventSaved,
state.applyModerationAction,
state.reactToJoinRequest,
)
relay.OnConnect = append(relay.OnConnect, khatru.RequestAuth)
return state
}
type Group struct {
nip29.Group
mu sync.RWMutex
}
func (s *State) NewGroup(id string) *Group {
return &Group{
Group: nip29.Group{
Address: nip29.GroupAddress{
ID: id,
Relay: "wss://" + s.Domain,
},
Members: map[string]*nip29.Role{},
},
}
}
// loadGroups loads all the group metadata from all the past action messages.
func (s *State) loadGroups(ctx context.Context) {
groupMetadataEvents, _ := s.DB.QueryEvents(ctx, nostr.Filter{Kinds: []int{nostr.KindSimpleGroupCreateGroup}})
for evt := range groupMetadataEvents {
gtag := evt.Tags.GetFirst([]string{"h", ""})
id := (*gtag)[1]
group := s.NewGroup(id)
f := nostr.Filter{
Kinds: nip29.ModerationEventKinds,
Tags: nostr.TagMap{"h": []string{id}},
}
ch, _ := s.DB.QueryEvents(ctx, f)
events := make([]*nostr.Event, 0)
for event := range ch {
events = append(events, event)
}
for i := len(events) - 1; i >= 0; i-- {
evt := events[i]
act, _ := nip29_relay.GetModerationAction(evt)
act.Apply(&group.Group)
}
s.Groups.Store(group.Address.ID, group)
}
}
func (s *State) GetGroupFromEvent(event *nostr.Event) *Group {
groupID := GetGroupIDFromEvent(event)
if groupID == "" {
return nil
}
group, _ := s.Groups.Load(groupID)
return group
}
func GetGroupIDFromEvent(event *nostr.Event) string {
gtag := event.Tags.GetFirst([]string{"h", ""})
if gtag == nil || len(*gtag) < 2 {
return ""
}
groupId := (*gtag)[1]
return groupId
}
func GetUsersFromEvent(event *nostr.Event) []string {
pTags := event.Tags.GetAll([]string{"p", ""})
if len(pTags) == 0 {
return nil
}
var users []string
for _, tag := range pTags {
if len(tag) > 1 {
users = append(users, tag[1])
}
}
return users
}

3
strfry29/README.adoc Normal file
View File

@ -0,0 +1,3 @@
= strfry29
== a plugin for turning strfry into a NIP-29-powered relay

28
strfry29/accept.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"context"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
)
func accept(event *nostr.Event) (reject bool, msg string) {
if nip29.MetadataEventKinds.Includes(event.Kind) {
return true, "can't write metadata event kinds directly"
}
for _, re := range []func(ctx context.Context, event *nostr.Event) (reject bool, msg string){
state.RequireHTagForExistingGroup,
state.RequireModerationEventsToBeRecent,
state.RestrictWritesBasedOnGroupRules,
state.RestrictInvalidModerationActions,
state.PreventWritingOfEventsJustDeleted,
} {
if reject, msg := re(ctx, event); reject {
return reject, msg
}
}
return false, ""
}

4
strfry29/justfile Normal file
View File

@ -0,0 +1,4 @@
run:
go build .
mkdir -p strfry-db
strfry --config=./strfry.conf relay

130
strfry29/main.go Normal file
View File

@ -0,0 +1,130 @@
package main
import (
"context"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/fiatjaf/eventstore/strfry"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
)
var (
state *relay29.State
ctx = context.Background()
strfrydb strfry.StrfryBackend
)
func main() {
incoming := json.NewDecoder(os.Stdin)
outgoing := json.NewEncoder(os.Stdout)
curr, _ := os.Getwd()
path := filepath.Join(curr, "strfry29.json")
confb, err := os.ReadFile(path)
if err != nil {
log.Fatalf("couldn't open config file at %s: %s", path, err)
return
}
var conf struct {
Domain string `json:"domain"`
RelaySecretKey string `json:"relay_secret_key"`
StrfryConfig string `json:"strfry_config_path"`
StrfryExecutable string `json:"strfry_executable_path"`
}
if err := json.Unmarshal(confb, &conf); err != nil {
log.Fatalf("invalid json config at %s: %s", path, err)
return
}
strfrydb = strfry.StrfryBackend{
ConfigPath: conf.StrfryConfig,
ExecutablePath: conf.StrfryExecutable,
}
strfrydb.Init()
defer strfrydb.Close()
state = relay29.New(relay29.Options{
Domain: conf.Domain,
DB: &strfrydb,
SecretKey: conf.RelaySecretKey,
})
state.AllowPrivateGroups = false
state.GetAuthed = func(ctx context.Context) string { return "" }
state.Relay = protoRelay{}
// rebuild metadata events (replaceable) for all groups and make them available
filter := nostr.Filter{Kinds: []int{nostr.KindSimpleGroupMetadata, nostr.KindSimpleGroupAdmins, nostr.KindSimpleGroupMembers}}
if err := republishMetadataEvents(filter); err != nil {
log.Fatalf("failed to republish metadata events on startup: %s", err)
return
}
for {
var msg StrfryMessage
err := incoming.Decode(&msg)
if err == io.EOF {
return
}
if err != nil {
log.Print("[strfry29] failed to decode request. killing: " + err.Error())
return
}
// message, _ := json.Marshal(msg)
// log.Print("[strfry29] got event: ", string(message))
if reject, rejectMsg := accept(msg.Event); reject {
outgoing.Encode(StrfryResponse{
ID: msg.Event.ID,
Action: "reject",
Msg: rejectMsg,
})
} else {
outgoing.Encode(StrfryResponse{
ID: msg.Event.ID,
Action: "accept",
})
go func() {
time.Sleep(time.Millisecond * 200)
state.ApplyModerationAction(ctx, msg.Event)
state.ReactToJoinRequest(ctx, msg.Event)
state.ReactToLeaveRequest(ctx, msg.Event)
}()
}
}
}
type StrfryMessage struct {
Type string `json:"type"`
Event *nostr.Event `json:"event"`
SourceType string `json:"sourceType"`
}
type StrfryResponse struct {
ID string `json:"id"`
Action string `json:"action"`
Msg string `json:"msg"`
}
type protoRelay struct{}
func (p protoRelay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
err := strfrydb.SaveEvent(ctx, evt)
return false, err
}
func (p protoRelay) BroadcastEvent(evt *nostr.Event) {
strfrydb.SaveEvent(ctx, evt)
}

44
strfry29/metadata.go Normal file
View File

@ -0,0 +1,44 @@
package main
import (
"context"
"fmt"
"github.com/nbd-wtf/go-nostr"
)
func republishMetadataEvents(basefilter nostr.Filter) error {
filter := basefilter
filter.Kinds = []int{nostr.KindSimpleGroupMetadata}
if err := republishMetadataEvent(state.MetadataQueryHandler, filter); err != nil {
return fmt.Errorf("with filter %s: %w", filter, err)
}
filter.Kinds = []int{nostr.KindSimpleGroupAdmins}
if err := republishMetadataEvent(state.AdminsQueryHandler, filter); err != nil {
return fmt.Errorf("with filter %s: %w", filter, err)
}
filter.Kinds = []int{nostr.KindSimpleGroupMembers}
if err := republishMetadataEvent(state.MembersQueryHandler, filter); err != nil {
return fmt.Errorf("with filter %s: %w", filter, err)
}
return nil
}
func republishMetadataEvent(querier func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error), filter nostr.Filter) error {
ch, err := querier(ctx, filter)
if err != nil {
return fmt.Errorf("failed to build: %s", err)
}
for evt := range ch {
if err := strfrydb.SaveEvent(ctx, evt); err != nil {
return fmt.Errorf("failed to publish: %w", err)
}
}
return nil
}

67
strfry29/strfry.conf Normal file
View File

@ -0,0 +1,67 @@
db = "strfry-db/"
dbParams {
maxreaders = 256
mapsize = 10995116277760
noReadAhead = false
}
events {
maxEventSize = 4096
rejectEventsNewerThanSeconds = 30
rejectEventsOlderThanSeconds = 30
rejectEphemeralEventsOlderThanSeconds = 60
ephemeralEventsLifetimeSeconds = 300
maxNumTags = 12
maxTagValSize = 256
}
relay {
bind = "127.0.0.1"
port = 52929
nofiles = 0
realIpHeader = ""
info {
name = "strfry29"
description = "this is an strfry instance that only works with nip29 groups"
pubkey = ""
contact = ""
icon = ""
}
maxWebsocketPayloadSize = 5000
autoPingSeconds = 29
enableTcpKeepalive = false
queryTimesliceBudgetMicroseconds = 10000
maxFilterLimit = 500
maxSubsPerConnection = 20
writePolicy {
plugin = "./strfry29"
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
invalidEvents = true
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
negentropy {
enabled = true
maxSyncEvents = 1000000
}
}

6
strfry29/strfry29.json Normal file
View File

@ -0,0 +1,6 @@
{
"domain": "example.com",
"relay_secret_key": "9e36f2b5dcc299a01019471ec13bc911b614245f39464f2496d32bf639c12c3b",
"strfry_config_path": "strfry.conf",
"strfry_executable_path": "/usr/local/bin/strfry"
}