turn into a library.

This commit is contained in:
fiatjaf 2024-07-05 00:32:46 -03:00
parent 3561167379
commit 68f6f52c82
13 changed files with 456 additions and 204 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 fiatjaf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

67
README.adoc Normal file
View File

@ -0,0 +1,67 @@
= relay29 image:https://pkg.go.dev/badge/github.com/fiatjaf/relay29.svg[link=https://pkg.go.dev/github.com/fiatjaf/relay29]
a library for creating NIP-29 relays, based on https://github.com/fiatjaf/khatru[khatru].
CAUTION: This is probably broken so please don't trust it for anything serious and be prepared to delete your database.
[source,go]
---
package main
import (
"fmt"
"log"
"net/http"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
func main() {
relayPrivateKey := nostr.GeneratePrivateKey()
state := relay29.Init(relay29.Options{
Domain: "localhost:2929",
DB: &slicestore.SliceStore{},
SecretKey: relayPrivateKey,
})
// init relay
state.Relay.Info.Name = "very ephemeral chat relay"
state.Relay.Info.PubKey, _ = nostr.GetPublicKey(relayPrivateKey)
state.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,
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),
policies.PreventTimestampsInTheFuture(30),
)
// http routes
state.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 {
log.Fatal("failed to serve")
}
}
---
== How to use
Basically you just call `relay29.Init()` and then you get back a `relay29.State` object that has a normal `khatru.Relay` inside and and 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.

View File

@ -1,73 +1,91 @@
package main
package relay29
import (
"context"
"time"
"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/rs/zerolog/log"
)
const TOO_OLD = 60 // seconds
const tooOld = 60 // seconds
// events that just got deleted will be cached here for TOO_OLD seconds such that someone doesn't rebroadcast
// them -- after that time we won't accept them anymore, so we can remove their ids from this cache
var deletedCache = set.NewSliceSet[string]()
func 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 {
return true, "missing group (`h`) tag"
}
if group := getGroupFromEvent(event); group == nil {
// skip this check when creating a group
if event.Kind == nostr.KindSimpleGroupCreateGroup {
return false, ""
}
// otherwise require a group to exist always
if group := s.GetGroupFromEvent(event); group == nil {
return true, "group '" + (*gtag)[1] + "' doesn't exist"
}
return false, ""
}
func restrictWritesBasedOnGroupRules(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// check it only for normal write events
if event.Kind == 9 || event.Kind == 11 {
group := getGroupFromEvent(event)
func (s *State) restrictWritesBasedOnGroupRules(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
group := s.GetGroupFromEvent(event)
// only members can write
if _, isMember := group.Members[event.PubKey]; !isMember {
return true, "unknown member"
if event.Kind == nostr.KindSimpleGroupJoinRequest {
// anyone can apply to enter any group (if this is not desired a policy must be added to filter out this stuff)
group.mu.RLock()
defer group.mu.RUnlock()
if _, isMemberAlready := group.Members[event.PubKey]; isMemberAlready {
// unless you're already a member
return true, "already a member"
}
return false, ""
}
if event.Kind == nostr.KindSimpleGroupCreateGroup {
// anyone can create new groups (if this is not desired a policy must be added to filter out this stuff)
return false, ""
}
// only members can write
group.mu.RLock()
defer group.mu.RUnlock()
if _, isMember := group.Members[event.PubKey]; !isMember {
return true, "unknown member"
}
return false, ""
}
func preventWritingOfEventsJustDeleted(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if deletedCache.Has(event.ID) {
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 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.MetadataEventKinds.Includes(event.Kind) {
return false, ""
}
// moderation action events must be new and not reused
if event.CreatedAt < nostr.Now()-TOO_OLD {
if event.CreatedAt < nostr.Now()-tooOld {
return true, "moderation action is too old (older than 1 minute ago)"
}
group := getGroupFromEvent(event)
group := s.GetGroupFromEvent(event)
if event.Kind == nostr.KindSimpleGroupCreateGroup && group != nil {
return true, "group already exists"
}
// will check if the moderation event author has sufficient permissions to perform this action
// except for the relay owner/pubkey, that has infinite permissions already
if event.PubKey == s.RelayPubkey {
if event.PubKey == s.publicKey {
return false, ""
}
@ -76,6 +94,8 @@ func restrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (
return true, "invalid moderation action: " + err.Error()
}
group.mu.RLock()
defer group.mu.RUnlock()
role, ok := group.Members[event.PubKey]
if !ok || role == nip29.EmptyRole {
return true, "unknown admin"
@ -86,23 +106,24 @@ func restrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (
return false, ""
}
func rateLimit(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
group := getGroupFromEvent(event)
if rsv := group.bucket.Reserve(); rsv.Delay() != 0 {
rsv.Cancel()
return true, "rate-limited"
} else {
rsv.OK()
return
}
}
func 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)
if err != nil {
return
}
group := getGroupFromEvent(event)
// get group (or create it)
var group *Group
if event.Kind == nostr.KindSimpleGroupCreateGroup {
// if it's a group creation event we create the group first
groupId := GetGroupIDFromEvent(event)
group = s.NewGroup(groupId)
s.Groups.Store(groupId, group)
} else {
group = s.GetGroupFromEvent(event)
}
// apply the moderation action
action.Apply(&group.Group)
// if it's a delete event we have to actually delete stuff from the database here
@ -114,19 +135,19 @@ func applyModerationAction(ctx context.Context, event *nostr.Event) {
log.Warn().Stringer("event", event).Msg("delete request came with a broken \"e\" tag")
continue
}
res, err := db.QueryEvents(ctx, nostr.Filter{IDs: []string{id}})
res, err := s.DB.QueryEvents(ctx, nostr.Filter{IDs: []string{id}})
if err != nil {
log.Warn().Err(err).Msg("failed to query event to be deleted")
continue
}
for target := range res {
if err := db.DeleteEvent(ctx, target); err != nil {
if err := s.DB.DeleteEvent(ctx, target); err != nil {
log.Warn().Err(err).Stringer("event", target).Msg("failed to delete")
} else {
deletedCache.Add(target.ID)
s.deletedCache.Add(target.ID)
go func(id string) {
time.Sleep(TOO_OLD * time.Second)
deletedCache.Remove(id)
time.Sleep(tooOld * time.Second)
s.deletedCache.Remove(id)
}(target.ID)
}
}
@ -134,29 +155,29 @@ func applyModerationAction(ctx context.Context, event *nostr.Event) {
}
}
// propagate new replaceable events to listeners
// propagate new replaceable events to listeners depending on what changed happened
switch event.Kind {
case nostr.KindSimpleGroupEditMetadata, nostr.KindSimpleGroupEditGroupStatus:
case nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupEditMetadata, nostr.KindSimpleGroupEditGroupStatus:
evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey)
relay.BroadcastEvent(evt)
evt.Sign(s.privateKey)
s.Relay.BroadcastEvent(evt)
case nostr.KindSimpleGroupAddPermission, nostr.KindSimpleGroupRemovePermission:
evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey)
relay.BroadcastEvent(evt)
evt.Sign(s.privateKey)
s.Relay.BroadcastEvent(evt)
case nostr.KindSimpleGroupAddUser, nostr.KindSimpleGroupRemoveUser:
evt := group.ToMembersEvent()
evt.Sign(s.RelayPrivkey)
relay.BroadcastEvent(evt)
evt.Sign(s.privateKey)
s.Relay.BroadcastEvent(evt)
}
}
func reactToJoinRequest(ctx context.Context, event *nostr.Event) {
if event.Kind != 9021 {
func (s *State) reactToJoinRequest(ctx context.Context, event *nostr.Event) {
if event.Kind != nostr.KindSimpleGroupJoinRequest {
return
}
group := getGroupFromEvent(event)
group := s.GetGroupFromEvent(event)
if !group.Closed {
// immediately add the requester
addUser := &nostr.Event{
@ -167,22 +188,14 @@ func reactToJoinRequest(ctx context.Context, event *nostr.Event) {
nostr.Tag{"p", event.PubKey},
},
}
if err := addUser.Sign(s.RelayPrivkey); err != nil {
if err := addUser.Sign(s.privateKey); err != nil {
log.Error().Err(err).Msg("failed to sign add-user event")
return
}
if _, err := relay.AddEvent(ctx, addUser); err != nil {
if _, err := s.Relay.AddEvent(ctx, addUser); err != nil {
log.Error().Err(err).Msg("failed to add user who requested to join")
return
}
relay.BroadcastEvent(addUser)
s.Relay.BroadcastEvent(addUser)
}
}
func blockDeletesOfOldMessages(ctx context.Context, target, deletion *nostr.Event) (acceptDeletion bool, msg string) {
if target.CreatedAt < nostr.Now()-60*60*2 /* 2 hours */ {
return false, "can't delete old event, contact relay admin"
}
return true, ""
}

52
examples/basic/main.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
func main() {
relayPrivateKey := nostr.GeneratePrivateKey()
state := relay29.Init(relay29.Options{
Domain: "localhost:2929",
DB: &slicestore.SliceStore{},
SecretKey: relayPrivateKey,
})
// init relay
state.Relay.Info.Name = "very ephemeral chat relay"
state.Relay.Info.PubKey, _ = nostr.GetPublicKey(relayPrivateKey)
state.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,
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),
policies.PreventTimestampsInTheFuture(30),
)
// http routes
state.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 {
log.Fatal("failed to serve")
}
}

View File

@ -0,0 +1,47 @@
package main
import (
"context"
"time"
"github.com/fiatjaf/relay29"
"github.com/nbd-wtf/go-nostr"
"github.com/puzpuzpuz/xsync/v3"
"golang.org/x/time/rate"
)
var internalCallContextKey = struct{}{}
func preventGroupCreation(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.Kind == 9007 && ctx.Value(internalCallContextKey) == nil {
return true, "to create groups open https://" + s.Domain + " in your web browser"
}
return false, ""
}
func blockDeletesOfOldMessages(ctx context.Context, target, deletion *nostr.Event) (acceptDeletion bool, msg string) {
if target.CreatedAt < nostr.Now()-60*60*2 /* 2 hours */ {
return false, "can't delete old event, contact relay admin"
}
return true, ""
}
// very strict rate limits
var rateLimitBuckets = xsync.NewMapOf[*relay29.Group, *rate.Limiter]()
func rateLimit(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
group := state.GetGroupFromEvent(event)
bucket, _ := rateLimitBuckets.LoadOrCompute(group, func() *rate.Limiter {
return rate.NewLimiter(rate.Every(time.Minute*2), 15)
})
if rsv := bucket.Reserve(); rsv.Delay() != 0 {
rsv.Cancel()
return true, "rate-limited"
} else {
rsv.OK()
return
}
}

View File

@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/binary"
"encoding/hex"
"fmt"
@ -39,15 +40,15 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
log.Info().Str("id", groupId).Str("owner", pubkey).Msg("making group")
group, _ := groups.Load(groupId)
group, _ := state.Groups.Load(groupId)
if group != nil {
http.Error(w, "group already exists", 403)
return
}
// create group right here
group = newGroup(groupId)
groups.Store(groupId, group)
group = state.NewGroup(groupId)
state.Groups.Store(groupId, group)
foundingEvents := []*nostr.Event{
{
@ -81,13 +82,16 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
},
},
}
ourCtx := context.WithValue(r.Context(), internalCallContextKey, &struct{}{})
for _, evt := range foundingEvents {
if err := evt.Sign(s.RelayPrivkey); err != nil {
log.Error().Err(err).Msg("error signing group creation event")
http.Error(w, "error signing group creation event: "+err.Error(), 500)
return
}
if _, err := relay.AddEvent(r.Context(), evt); err != nil {
if _, err := state.Relay.AddEvent(ourCtx, evt); err != nil {
log.Error().Err(err).Stringer("event", evt).Msg("failed to save group creation event")
http.Error(w, "failed to save group creation event", 501)
return

View File

@ -1,13 +1,13 @@
package main
import (
"context"
"net/http"
"os"
"slices"
"github.com/fiatjaf/eventstore/bolt"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
"github.com/fiatjaf/relay29"
"github.com/kelseyhightower/envconfig"
"github.com/nbd-wtf/go-nostr"
"github.com/rs/zerolog"
@ -30,7 +30,7 @@ var (
s Settings
db = &bolt.BoltBackend{}
log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger()
relay = khatru.NewRelay()
state *relay29.State
)
func main() {
@ -49,61 +49,44 @@ func main() {
}
log.Debug().Str("path", db.Path).Msg("initialized database")
// load all groups
loadGroups(context.Background())
// init relay29 stuff
state = relay29.Init(relay29.Options{
Domain: s.Domain,
DB: db,
SecretKey: s.RelayPrivkey,
})
// init relay
relay.Info.Name = s.RelayName
relay.Info.PubKey = s.RelayPubkey
relay.Info.Description = s.RelayDescription
relay.Info.Contact = s.RelayContact
relay.Info.Icon = s.RelayIcon
relay.Info.SupportedNIPs = append(relay.Info.SupportedNIPs, 29)
state.Relay.Info.Name = s.RelayName
state.Relay.Info.PubKey = s.RelayPubkey
state.Relay.Info.Description = s.RelayDescription
state.Relay.Info.Contact = s.RelayContact
state.Relay.Info.Icon = s.RelayIcon
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents,
normalEventQuery,
metadataQueryHandler,
adminsQueryHandler,
membersQueryHandler,
)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.OverwriteDeletionOutcome = append(relay.OverwriteDeletionOutcome,
state.Relay.OverwriteDeletionOutcome = append(state.Relay.OverwriteDeletionOutcome,
blockDeletesOfOldMessages,
)
relay.RejectFilter = append(relay.RejectFilter,
requireKindAndSingleGroupIDOrSpecificEventReference,
)
relay.RejectEvent = append(relay.RejectEvent,
requireHTagForExistingGroup,
state.Relay.RejectEvent = slices.Insert(state.Relay.RejectEvent, 0,
policies.PreventLargeTags(64),
policies.PreventTooManyIndexableTags(6, []int{9005}, nil),
policies.RestrictToSpecifiedKinds(
9, 11, 12,
9, 10, 11, 12,
30023, 31922, 31923, 9802,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9021,
),
policies.PreventTimestampsInThePast(60),
policies.PreventTimestampsInTheFuture(30),
restrictWritesBasedOnGroupRules,
restrictInvalidModerationActions,
rateLimit,
preventWritingOfEventsJustDeleted,
preventGroupCreation,
)
relay.OnEventSaved = append(relay.OnEventSaved,
applyModerationAction,
reactToJoinRequest,
)
relay.OnConnect = append(relay.OnConnect, khatru.RequestAuth)
// http routes
relay.Router().HandleFunc("/create", handleCreateGroup)
relay.Router().HandleFunc("/", handleHomepage)
state.Relay.Router().HandleFunc("/create", handleCreateGroup)
state.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, relay); err != nil {
if err := http.ListenAndServe(":"+s.Port, state.Relay); err != nil {
log.Fatal().Err(err).Msg("failed to serve")
}
}

View File

@ -1,4 +1,4 @@
package main
package relay29
import (
"context"
@ -9,7 +9,7 @@ import (
"github.com/nbd-wtf/go-nostr/nip29"
)
func 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
@ -44,7 +44,7 @@ func requireKindAndSingleGroupIDOrSpecificEventReference(ctx context.Context, fi
if tags, ok := filter.Tags["h"]; ok && len(tags) > 0 {
// "h" tags specified
for _, tag := range tags {
if group, _ := groups.Load(tag); group != nil {
if group, _ := s.Groups.Load(tag); group != nil {
if !group.Private {
continue // fine, this is public
}

View File

@ -1,75 +0,0 @@
package main
import (
"context"
"strings"
"sync"
"time"
"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"
"golang.org/x/time/rate"
)
type Group struct {
nip29.Group
bucket *rate.Limiter
mu sync.RWMutex
}
var groups = xsync.NewMapOf[string, *Group]()
func newGroup(id string) *Group {
return &Group{
Group: nip29.Group{
Address: nip29.GroupAddress{
ID: id,
Relay: "wss://" + s.Domain,
},
Members: map[string]*nip29.Role{},
},
// very strict rate limits
bucket: rate.NewLimiter(rate.Every(time.Minute*2), 15),
}
}
// loadGroups loads all the group metadata from all the past action messages
func loadGroups(ctx context.Context) {
groupMetadataEvents, _ := db.QueryEvents(ctx, nostr.Filter{Limit: db.MaxLimit, Kinds: []int{nostr.KindSimpleGroupCreateGroup}})
for evt := range groupMetadataEvents {
gtag := evt.Tags.GetFirst([]string{"h", ""})
id := (*gtag)[1]
group := newGroup(id)
f := nostr.Filter{
Limit: 5000, Kinds: nip29.ModerationEventKinds, Tags: nostr.TagMap{"h": []string{id}},
}
ch, _ := 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, _ := nip29_relay.GetModerationAction(evt)
act.Apply(&group.Group)
}
groups.Store(group.Address.ID, group)
}
}
func getGroupFromEvent(event *nostr.Event) *Group {
gtag := event.Tags.GetFirst([]string{"h", ""})
groupId := (*gtag)[1]
group, _ := groups.Load(groupId)
return group
}
func groupComparator(g *Group, id string) int {
return strings.Compare(g.Address.ID, id)
}

View File

@ -1,4 +1,4 @@
package main
package relay29
import (
"context"
@ -9,14 +9,14 @@ import (
"golang.org/x/exp/slices"
)
func 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)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupMetadata) {
if _, ok := filter.Tags["d"]; !ok {
// no "d" tag specified, return everything
groups.Range(func(_ string, group *Group) bool {
s.Groups.Range(func(_ string, group *Group) bool {
if group.Private {
// don't reveal metadata about private groups in lists
return true
@ -25,15 +25,15 @@ func metadataQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr
}
evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
return true
})
} else {
for _, groupId := range filter.Tags["d"] {
if group, _ := groups.Load(groupId); group != nil {
if group, _ := s.Groups.Load(groupId); group != nil {
evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
}
}
@ -46,33 +46,33 @@ func metadataQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr
return ch, nil
}
func 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)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupAdmins) {
if _, ok := filter.Tags["d"]; !ok {
// no "d" tag specified, return everything
groups.Range(func(_ string, group *Group) bool {
s.Groups.Range(func(_ string, group *Group) bool {
if group.Private {
// don't reveal lists of admins of private groups ever
return true
}
evt := group.ToAdminsEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
return true
})
} else {
for _, groupId := range filter.Tags["d"] {
if group, _ := groups.Load(groupId); group != nil {
if group, _ := s.Groups.Load(groupId); group != nil {
if group.Private {
// don't reveal lists of admins of private groups ever
continue
}
evt := group.ToAdminsEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
}
}
@ -85,32 +85,32 @@ func adminsQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.E
return ch, nil
}
func 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)
go func() {
if slices.Contains(filter.Kinds, nostr.KindSimpleGroupMembers) {
if _, ok := filter.Tags["d"]; !ok {
// no "d" tag specified, return everything
groups.Range(func(_ string, group *Group) bool {
s.Groups.Range(func(_ string, group *Group) bool {
if group.Private {
// don't reveal lists of members of private groups ever
return true
}
evt := group.ToMembersEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
return true
})
} else {
for _, groupId := range filter.Tags["d"] {
if group, _ := groups.Load(groupId); group != nil {
if group, _ := s.Groups.Load(groupId); group != nil {
if group.Private {
// don't reveal lists of members of private groups ever
continue
}
evt := group.ToMembersEvent()
evt.Sign(s.RelayPrivkey)
evt.Sign(s.privateKey)
ch <- evt
}
}
@ -123,10 +123,10 @@ func membersQueryHandler(ctx context.Context, filter nostr.Filter) (chan *nostr.
return ch, nil
}
func 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 db.QueryEvents(ctx, filter)
return s.DB.QueryEvents(ctx, filter)
}
ch := make(chan *nostr.Event)
@ -136,11 +136,11 @@ func normalEventQuery(ctx context.Context, filter nostr.Filter) (chan *nostr.Eve
var results chan *nostr.Event
var err error
if refE, ok := filter.Tags["e"]; ok && len(refE) > 0 {
results, err = db.QueryEvents(ctx, filter)
results, err = s.DB.QueryEvents(ctx, filter)
} else if refA, ok := filter.Tags["a"]; ok && len(refA) > 0 {
results, err = db.QueryEvents(ctx, filter)
results, err = s.DB.QueryEvents(ctx, filter)
} else if len(filter.IDs) > 0 {
results, err = db.QueryEvents(ctx, filter)
results, err = s.DB.QueryEvents(ctx, filter)
} else {
// we must end here for all the metadata queries and so on otherwise they will never close
close(ch)
@ -152,7 +152,7 @@ func normalEventQuery(ctx context.Context, filter nostr.Filter) (chan *nostr.Eve
allowed := set.NewSliceSet[string]()
disallowed := set.NewSliceSet[string]()
for evt := range results {
if group := getGroupFromEvent(evt); !group.Private || allowed.Has(group.Address.ID) {
if group := s.GetGroupFromEvent(evt); !group.Private || allowed.Has(group.Address.ID) {
ch <- evt
} else if authed != "" && !disallowed.Has(group.Address.ID) {
group.mu.RLock()

140
state.go Normal file
View File

@ -0,0 +1,140 @@
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
deletedCache set.Set[string]
publicKey string
privateKey string
}
type Options struct {
Domain string
DB eventstore.Store
SecretKey string
}
func Init(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
// them -- after that time we won't accept them anymore, so we can remove their ids from this cache
deletedCache := set.NewSliceSet[string]()
// 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.SupportedNIPs = append(relay.Info.SupportedNIPs, 29)
state := &State{
relay,
opts.Domain,
groups,
opts.DB,
deletedCache,
pubkey,
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.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{
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, _ := nip29_relay.GetModerationAction(evt)
act.Apply(&group.Group)
}
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
}