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 ( import (
"context" "context"
"time" "time"
"github.com/fiatjaf/set"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29" "github.com/nbd-wtf/go-nostr/nip29"
nip29_relay "github.com/nbd-wtf/go-nostr/nip29/relay" 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 func (s *State) requireHTagForExistingGroup(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// them -- after that time we won't accept them anymore, so we can remove their ids from this cache // this allows us to never check again in any of the other rules if the tag exists and just assume it exists always
var deletedCache = set.NewSliceSet[string]()
func requireHTagForExistingGroup(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
gtag := event.Tags.GetFirst([]string{"h", ""}) gtag := event.Tags.GetFirst([]string{"h", ""})
if gtag == nil { if gtag == nil {
return true, "missing group (`h`) tag" 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 true, "group '" + (*gtag)[1] + "' doesn't exist"
} }
return false, "" return false, ""
} }
func 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) {
// check it only for normal write events group := s.GetGroupFromEvent(event)
if event.Kind == 9 || event.Kind == 11 {
group := getGroupFromEvent(event)
// only members can write if event.Kind == nostr.KindSimpleGroupJoinRequest {
if _, isMember := group.Members[event.PubKey]; !isMember { // anyone can apply to enter any group (if this is not desired a policy must be added to filter out this stuff)
return true, "unknown member" 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, "" return false, ""
} }
func 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 deletedCache.Has(event.ID) { if s.deletedCache.Has(event.ID) {
return true, "this was deleted" return true, "this was deleted"
} }
return false, "" 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) { if !nip29.MetadataEventKinds.Includes(event.Kind) {
return false, "" return false, ""
} }
// moderation action events must be new and not reused // 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)" 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 { if event.Kind == nostr.KindSimpleGroupCreateGroup && group != nil {
return true, "group already exists" return true, "group already exists"
} }
// will check if the moderation event author has sufficient permissions to perform this action // 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 // except for the relay owner/pubkey, that has infinite permissions already
if event.PubKey == s.RelayPubkey { if event.PubKey == s.publicKey {
return false, "" return false, ""
} }
@ -76,6 +94,8 @@ func restrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (
return true, "invalid moderation action: " + err.Error() return true, "invalid moderation action: " + err.Error()
} }
group.mu.RLock()
defer group.mu.RUnlock()
role, ok := group.Members[event.PubKey] role, ok := group.Members[event.PubKey]
if !ok || role == nip29.EmptyRole { if !ok || role == nip29.EmptyRole {
return true, "unknown admin" return true, "unknown admin"
@ -86,23 +106,24 @@ func restrictInvalidModerationActions(ctx context.Context, event *nostr.Event) (
return false, "" return false, ""
} }
func rateLimit(ctx context.Context, event *nostr.Event) (reject bool, msg string) { func (s *State) applyModerationAction(ctx context.Context, event *nostr.Event) {
group := getGroupFromEvent(event) // turn event into a moderation action processor
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) {
action, err := nip29_relay.GetModerationAction(event) action, err := nip29_relay.GetModerationAction(event)
if err != nil { if err != nil {
return 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) action.Apply(&group.Group)
// if it's a delete event we have to actually delete stuff from the database here // 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") log.Warn().Stringer("event", event).Msg("delete request came with a broken \"e\" tag")
continue 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 { if err != nil {
log.Warn().Err(err).Msg("failed to query event to be deleted") log.Warn().Err(err).Msg("failed to query event to be deleted")
continue continue
} }
for target := range res { 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") log.Warn().Err(err).Stringer("event", target).Msg("failed to delete")
} else { } else {
deletedCache.Add(target.ID) s.deletedCache.Add(target.ID)
go func(id string) { go func(id string) {
time.Sleep(TOO_OLD * time.Second) time.Sleep(tooOld * time.Second)
deletedCache.Remove(id) s.deletedCache.Remove(id)
}(target.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 { switch event.Kind {
case nostr.KindSimpleGroupEditMetadata, nostr.KindSimpleGroupEditGroupStatus: case nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupEditMetadata, nostr.KindSimpleGroupEditGroupStatus:
evt := group.ToMetadataEvent() evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey) evt.Sign(s.privateKey)
relay.BroadcastEvent(evt) s.Relay.BroadcastEvent(evt)
case nostr.KindSimpleGroupAddPermission, nostr.KindSimpleGroupRemovePermission: case nostr.KindSimpleGroupAddPermission, nostr.KindSimpleGroupRemovePermission:
evt := group.ToMetadataEvent() evt := group.ToMetadataEvent()
evt.Sign(s.RelayPrivkey) evt.Sign(s.privateKey)
relay.BroadcastEvent(evt) s.Relay.BroadcastEvent(evt)
case nostr.KindSimpleGroupAddUser, nostr.KindSimpleGroupRemoveUser: case nostr.KindSimpleGroupAddUser, nostr.KindSimpleGroupRemoveUser:
evt := group.ToMembersEvent() evt := group.ToMembersEvent()
evt.Sign(s.RelayPrivkey) evt.Sign(s.privateKey)
relay.BroadcastEvent(evt) s.Relay.BroadcastEvent(evt)
} }
} }
func reactToJoinRequest(ctx context.Context, event *nostr.Event) { func (s *State) reactToJoinRequest(ctx context.Context, event *nostr.Event) {
if event.Kind != 9021 { if event.Kind != nostr.KindSimpleGroupJoinRequest {
return return
} }
group := getGroupFromEvent(event) group := s.GetGroupFromEvent(event)
if !group.Closed { if !group.Closed {
// immediately add the requester // immediately add the requester
addUser := &nostr.Event{ addUser := &nostr.Event{
@ -167,22 +188,14 @@ func reactToJoinRequest(ctx context.Context, event *nostr.Event) {
nostr.Tag{"p", event.PubKey}, 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") log.Error().Err(err).Msg("failed to sign add-user event")
return 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") log.Error().Err(err).Msg("failed to add user who requested to join")
return 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 package main
import ( import (
"context"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@ -39,15 +40,15 @@ func handleCreateGroup(w http.ResponseWriter, r *http.Request) {
log.Info().Str("id", groupId).Str("owner", pubkey).Msg("making group") log.Info().Str("id", groupId).Str("owner", pubkey).Msg("making group")
group, _ := groups.Load(groupId) group, _ := state.Groups.Load(groupId)
if group != nil { if group != nil {
http.Error(w, "group already exists", 403) http.Error(w, "group already exists", 403)
return return
} }
// create group right here // create group right here
group = newGroup(groupId) group = state.NewGroup(groupId)
groups.Store(groupId, group) state.Groups.Store(groupId, group)
foundingEvents := []*nostr.Event{ 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 { for _, evt := range foundingEvents {
if err := evt.Sign(s.RelayPrivkey); err != nil { if err := evt.Sign(s.RelayPrivkey); err != nil {
log.Error().Err(err).Msg("error signing group creation event") log.Error().Err(err).Msg("error signing group creation event")
http.Error(w, "error signing group creation event: "+err.Error(), 500) http.Error(w, "error signing group creation event: "+err.Error(), 500)
return 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") log.Error().Err(err).Stringer("event", evt).Msg("failed to save group creation event")
http.Error(w, "failed to save group creation event", 501) http.Error(w, "failed to save group creation event", 501)
return return

View File

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

View File

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