turn into a library.
This commit is contained in:
parent
3561167379
commit
68f6f52c82
21
LICENSE
Normal file
21
LICENSE
Normal 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
67
README.adoc
Normal 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.
|
137
event_policy.go
137
event_policy.go
@ -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
52
examples/basic/main.go
Normal 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")
|
||||
}
|
||||
}
|
47
examples/groups.fiatjaf.com/extra_policies.go
Normal file
47
examples/groups.fiatjaf.com/extra_policies.go
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
@ -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")
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
75
groups.go
75
groups.go
@ -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)
|
||||
}
|
44
queries.go
44
queries.go
@ -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
140
state.go
Normal 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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user