From 86a6eec127138bccb780c9229b2f46b6d9c05a37 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 26 Sep 2025 11:38:08 -0700 Subject: [PATCH] Add handlers --- README.md | 11 ++ zooid/blossom.go | 12 +- zooid/config.go | 20 ++- zooid/events.go | 44 +++--- zooid/groups.go | 327 +++++++------------------------------------- zooid/instance.go | 298 ++++++++++++++++++++++++++++++++++------ zooid/management.go | 6 +- zooid/util.go | 16 +++ 8 files changed, 385 insertions(+), 349 deletions(-) diff --git a/README.md b/README.md index 09289f3..b75e7ec 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Optional: - `pubkey` - the public key of the relay owner. Does not affect access controls. - `description` - your relay's description. +### `[policy]` + +Contains policy and access related configuration. + +- `strip_signatures` - whether to remove signatures when serving events. This requires clients/users to trust the relay to properly authenticate signatures. Be cautious about using this; a malicious relay will be able to execute all kinds of attacks, including potentially serving events unrelated to a community use case. + ### `[groups]` Configures NIP 29 support. @@ -74,6 +80,9 @@ name = "My relay" schema = 'my_relay' secret = "ce30b1831a4551f4cb7a984033c34ab96d8cf56ff50df9d0c27d9fa5422f2278" +[policy] +strip_signatures = false + [groups] enabled = true auto_join = false @@ -100,6 +109,8 @@ See `justfile` for defined commands. ## TODO - [ ] See if we can build groups directly on top of the event store by generating events eagerly rather than lazily +- [ ] See if we can implement invites/redemptions directly on top of the event store by storing generated claims and redemptions. Avoid serving these to other people. - [ ] Add admin/owner/etc to list allowed pubkeys - [ ] Watch configuration files and hot reload - [ ] Free up resources after instance inactivity +- [ ] Admins/members diff --git a/zooid/blossom.go b/zooid/blossom.go index b80145e..40f6a6c 100644 --- a/zooid/blossom.go +++ b/zooid/blossom.go @@ -4,8 +4,8 @@ import ( "bytes" "context" "io" - "os" "net/url" + "os" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore" @@ -14,9 +14,9 @@ import ( ) type BlossomStore struct { - Config *Config - Schema *Schema - Store eventstore.Store + Config *Config + Schema *Schema + Store eventstore.Store } func (bl *BlossomStore) Init() error { @@ -26,11 +26,11 @@ func (bl *BlossomStore) Init() error { return err } - // Blossom uses a wrapped event store for metadata + // Blossom uses a wrapped event store for metadata bl.Store = &EventStore{Schema: bl.Schema} if err := bl.Store.Init(); err != nil { - return err + return err } return nil diff --git a/zooid/config.go b/zooid/config.go index 2ef400a..e69da49 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -15,7 +15,7 @@ type Role struct { } type Config struct { - Host string + Host string Self struct { Name string `toml:"name"` Icon string `toml:"icon"` @@ -25,6 +25,10 @@ type Config struct { Description string `toml:"description"` } `toml:"self"` + Policy struct { + StripSignatures bool `toml:"strip_signatures"` + } `toml:"policy"` + Groups struct { Enabled bool `toml:"enabled"` AutoJoin bool `toml:"auto_join"` @@ -79,8 +83,8 @@ func (config *Config) GetRolesForPubkey(pubkey nostr.PubKey) []Role { return roles } -func (config *Config) CanManage(roles []Role) bool { - for _, role := range roles { +func (config *Config) CanManage(pubkey nostr.PubKey) bool { + for _, role := range config.GetRolesForPubkey(pubkey) { if role.CanManage { return true } @@ -88,3 +92,13 @@ func (config *Config) CanManage(roles []Role) bool { return false } + +func (config *Config) CanInvite(pubkey nostr.PubKey) bool { + for _, role := range config.GetRolesForPubkey(pubkey) { + if role.CanInvite { + return true + } + } + + return false +} diff --git a/zooid/events.go b/zooid/events.go index a57e444..079e119 100644 --- a/zooid/events.go +++ b/zooid/events.go @@ -100,12 +100,11 @@ func (events *EventStore) QueryEvents(filter nostr.Filter, maxLimit int) iter.Se return } - limit := maxLimit - if filter.Limit > 0 && filter.Limit < limit { - limit = filter.Limit - } + if maxLimit > 0 && maxLimit < filter.Limit { + filter.Limit = maxLimit + } - rows, err := events.buildSelectQuery(filter, limit).RunWith(GetDb()).Query() + rows, err := events.buildSelectQuery(filter).RunWith(GetDb()).Query() if err != nil { return } @@ -159,7 +158,7 @@ func (events *EventStore) QueryEvents(filter nostr.Filter, maxLimit int) iter.Se } } -func (events *EventStore) buildSelectQuery(filter nostr.Filter, limit int) squirrel.SelectBuilder { +func (events *EventStore) buildSelectQuery(filter nostr.Filter) squirrel.SelectBuilder { qb := squirrel.Select("id", "created_at", "kind", "pubkey", "content", "tags", "sig"). From(events.Schema.Prefix("events")). OrderBy("created_at DESC") @@ -206,25 +205,26 @@ func (events *EventStore) buildSelectQuery(filter nostr.Filter, limit int) squir } for tagKey, tagValues := range filter.Tags { - if len(tagValues) > 0 && len(tagKey) == 1 { - tagValueInterfaces := make([]interface{}, len(tagValues)) - for i, tagValue := range tagValues { - tagValueInterfaces[i] = tagValue - } + if len(tagValues) == 0 { + continue + } - subQuery := squirrel.Select("event_id"). - From(events.Schema.Prefix("event_tags")). - Where(squirrel.Eq{"key": tagKey}). - Where(squirrel.Eq{"value": tagValueInterfaces}) - - subQuerySql, subQueryArgs, _ := subQuery.ToSql() - qb = qb.Where("id IN ("+subQuerySql+")", subQueryArgs...) + tagValueInterfaces := make([]interface{}, len(tagValues)) + for i, tagValue := range tagValues { + tagValueInterfaces[i] = tagValue } + + subQuery := squirrel.Select("event_id"). + From(events.Schema.Prefix("event_tags")). + Where(squirrel.Eq{"key": tagKey}). + Where(squirrel.Eq{"value": tagValueInterfaces}) + + subQuerySql, subQueryArgs, _ := subQuery.ToSql() + qb = qb.Where("id IN ("+subQuerySql+")", subQueryArgs...) } - // Add limit - if limit > 0 { - qb = qb.Limit(uint64(limit)) + if filter.Limit > 0 { + qb = qb.Limit(uint64(filter.Limit)) } return qb @@ -316,7 +316,7 @@ func (events *EventStore) ReplaceEvent(evt nostr.Event) error { func (events *EventStore) CountEvents(filter nostr.Filter) (uint32, error) { // Build a count query based on the select query but with COUNT(*) instead - qb := events.buildSelectQuery(filter, 0) + qb := events.buildSelectQuery(filter) // Convert the select query to a count query countQb := squirrel.Select("COUNT(*)").FromSelect(qb, "subquery") diff --git a/zooid/groups.go b/zooid/groups.go index f0b5559..b4334a7 100644 --- a/zooid/groups.go +++ b/zooid/groups.go @@ -1,152 +1,56 @@ package zooid import ( - "context" - "encoding/json" - "fmt" - "log" + "iter" "fiatjaf.com/nostr" - "fiatjaf.com/nostr/nip29" ) -type GroupsStore struct { - Host string - Config *Config - Schema *Schema -} +func GetGroupIDFromEvent(event nostr.Event) string { + tag := event.Tags.Find("h") -func (groups *GroupsStore) Init() error { - schema := groups.Schema.Render(` - CREATE TABLE IF NOT EXISTS {{.Prefix}}__groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - about TEXT NOT NULL, - closed BOOLEAN NOT NULL, - private BOOLEAN NOT NULL, - last_metadata_update INTEGER, - last_admins_update INTEGER, - last_members_update INTEGER - ); - - CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_groups_id ON {{.Prefix}}__groups(id); - - CREATE TABLE IF NOT EXISTS {{.Prefix}}__group_members ( - id TEXT PRIMARY KEY, - group_id TEXT NOT NULL, - pubkey TEXT NOT NULL, - FOREIGN KEY (group_id) REFERENCES {{.Prefix}}__groups(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_group_members_group_id ON {{.Prefix}}__group_members(group_id); - CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_group_members_pubkey ON {{.Prefix}}__group_members(pubkey); - `) - - if _, err := GetDb().Exec(schema); err != nil { - return fmt.Errorf("failed to create schema: %w", err) + if tag != nil { + return tag[1] } - return nil + return "" } -// Group CRUD - -func (groups *GroupsStore) SelectGroups() squirrel.SelectBuilder { - return squirrel.Select("id", "name", "about", "closed", "private", "last_metadata_update", "last_admins_update", "last_members_update").From(groups.Schema.Prefix("groups")) -} - -func (groups *GroupsStore) QueryGroups(builder squirrel.SelectBuilder) []Group { - rows, err := builder.RunWith(GetDb()).Query() - if err != nil { - return []Group{} - } - defer rows.Close() - - var groups []Group - for rows.Next() { - var group Group - var id string - err := rows.Scan(&id, &group.Name, &group.About, &group.Closed, &group.Private, &group.LastMetadataUpdate, &group.LastAdminsUpdate, &group.LastMembersUpdate) - if err != nil { - continue - } - - group.Address = nip29.GroupAddress{ - ID: id - Relay: groups.Config.Host - } - - groups = append(groups, group) - } - - return groups -} - -func (groups *GroupStore) PutGroup(group *nip29.Group) { - // Insert, on duplicate update -} - -func (groups *GroupStore) DeleteGroup(id string) { - // Delete group -} - -func (groups *GroupsStore) GetGroups() []nip29.Group { - return groups.QueryGroups(groups.SelectGroups()) -} - -func (groups *GroupsStore) GetGroupByID(id string) (nip29.Group, bool) { - groupList := groups.QueryGroups(groups.SelectGroups().Where(squirrel.Eq{"id": id})) - - return First(groupList), len(groupList) > 0 -} - -// Group Utils - -func (groups *GroupStore) MakeGroup(h string) *nip29.Group { - qualifiedID := fmt.Sprintf("%s'%s", groups.Config.Host, h) - group, err := nip29.NewGroup(qualifiedID) - if err != nil { - log.Printf("Failed to create group with qualified ID %s", qualifiedID) - return nil - } - - return &group -} - -func (groups *GroupStore) GetGroupIDFromEvent(event *nostr.Event) string { - hTag := event.Tags.GetFirst([]string{"h"}) - if hTag == nil { - return "" - } - - return hTag.Value() -} - -func (groups *GroupStore) GetGroupFromEvent(event *nostr.Event) *nip29.Group { - id = GetGroupIDFromEvent(event) - - if id == "" { - return nil - } - - return GetGroupByID(id) -} - -func (groups *GroupStore) IsGroupMember(ctx context.Context, id string, pubkey string) bool { - filter := nostr.Filter{ - Kinds: []int{nostr.KindSimpleGroupPutUser, nostr.KindSimpleGroupRemoveUser}, +func MakeGroupMetadataFilter(h string) nostr.Filter { + return nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata}, Tags: nostr.TagMap{ - "p": []string{pubkey}, - "h": []string{id}, + "a": []string{h}, }, } +} - events, err := GetBackend().QueryEvents(ctx, filter) - - if err != nil { - log.Println(err) +func MakeGroupEventFilters(h string) []nostr.Filter { + return []nostr.Filter{ + { + Tags: nostr.TagMap{ + "a": []string{h}, + }, + }, + { + Tags: nostr.TagMap{ + "h": []string{h}, + }, + }, } +} +func MakeGroupMembershipCheckFilter(h string, pubkey nostr.PubKey) nostr.Filter { + return nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindSimpleGroupPutUser, nostr.KindSimpleGroupRemoveUser}, + Tags: nostr.TagMap{ + "p": []string{pubkey.Hex()}, + "h": []string{h}, + }, + } +} + +func CheckGroupMembership(events iter.Seq[nostr.Event]) bool { for event := range events { if event.Kind == nostr.KindSimpleGroupPutUser { return true @@ -160,161 +64,32 @@ func (groups *GroupStore) IsGroupMember(ctx context.Context, id string, pubkey s return false } -func HandleCreateGroup(event *nostr.Event) { - group := MakeGroup(GetGroupIDFromEvent(event)) - - if group != nil { - PutGroup(group) - } -} - -func HandleEditMetadata(event *nostr.Event) { - group := GetGroupFromEvent(event) - - if group == nil { - group = MakeGroup(GetGroupIDFromEvent(event)) - } - - group.LastMetadataUpdate = event.CreatedAt - group.Name = group.Address.ID - - if tag := event.Tags.GetFirst([]string{"name", ""}); tag != nil { - group.Name = (*tag)[1] - } - if tag := event.Tags.GetFirst([]string{"about", ""}); tag != nil { - group.About = (*tag)[1] - } - if tag := event.Tags.GetFirst([]string{"picture", ""}); tag != nil { - group.Picture = (*tag)[1] - } - - if tag := event.Tags.GetFirst([]string{"private"}); tag != nil { - group.Private = true - } - if tag := event.Tags.GetFirst([]string{"closed"}); tag != nil { - group.Closed = true - } - - PutGroup(group) -} - -func HandleDeleteGroup(event *nostr.Event) { - ctx := context.Background() - id := GetGroupIDFromEvent(event) - - DeleteGroup(id) - - hFilter := nostr.Filter{ - Tags: nostr.TagMap{ - "h": []string{id}, - }, - } - - hCh, err := GetBackend().QueryEvents(ctx, hFilter) - if err != nil { - log.Println(err) - } else { - for event := range hCh { - DeleteEvent(ctx, event) - } - } - - dFilter := nostr.Filter{ - Tags: nostr.TagMap{ - "d": []string{id}, - }, - } - - dCh, err := GetBackend().QueryEvents(ctx, dFilter) - if err != nil { - log.Println(err) - } else { - for event := range dCh { - DeleteEvent(ctx, event) - } - } -} - -func GenerateGroupMetadataEvents(ctx context.Context, filter nostr.Filter) []*nostr.Event { - result := make([]*nostr.Event, 0) - - for _, group := range ListGroups() { - event := group.ToMetadataEvent() - - if !filter.Matches(event) { - continue - } - - if err := event.Sign(RELAY_SECRET); err != nil { - log.Println("Failed to sign metadata event", err) - } else { - result = append(result, event) - } - } - - return result -} - -func GenerateGroupAdminsEvents(ctx context.Context, filter nostr.Filter) []*nostr.Event { - result := make([]*nostr.Event, 0) - - for _, group := range ListGroups() { - event := nostr.Event{ - Kind: nostr.KindSimpleGroupAdmins, - CreatedAt: nostr.Now(), - Tags: nostr.Tags{ - nostr.Tag{"d", group.Address.ID}, - }, - } - - for _, pubkey := range RELAY_ADMINS { - event.Tags = append(event.Tags, nostr.Tag{"p", pubkey}) - } - - if !filter.Matches(&event) { - continue - } - - if err := event.Sign(RELAY_SECRET); err != nil { - log.Println("Failed to sign admins event", err) - } else { - result = append(result, &event) - } - } - - return result -} - -func MakePutUserEvent(event *nostr.Event) *nostr.Event { - putUser := nostr.Event{ +func MakePutUserEvent(h string, pubkey nostr.PubKey) nostr.Event { + return nostr.Event{ Kind: nostr.KindSimpleGroupPutUser, CreatedAt: nostr.Now(), Tags: nostr.Tags{ - nostr.Tag{"p", event.PubKey}, - nostr.Tag{"h", GetGroupIDFromEvent(event)}, + nostr.Tag{"p", pubkey.Hex()}, + nostr.Tag{"h", h}, }, } - - if err := putUser.Sign(RELAY_SECRET); err != nil { - log.Println(err) - } - - return &putUser } -func MakeRemoveUserEvent(event *nostr.Event) *nostr.Event { - removeUser := nostr.Event{ +func MakeRemoveUserEvent(h string, pubkey nostr.PubKey) nostr.Event { + return nostr.Event{ Kind: nostr.KindSimpleGroupRemoveUser, CreatedAt: nostr.Now(), Tags: nostr.Tags{ - nostr.Tag{"p", event.PubKey}, - nostr.Tag{"h", GetGroupIDFromEvent(event)}, + nostr.Tag{"p", pubkey.Hex()}, + nostr.Tag{"h", h}, }, } - - if err := removeUser.Sign(RELAY_SECRET); err != nil { - log.Println(err) - } - - return &removeUser +} + +func MakeMetadataEvent(event nostr.Event) nostr.Event { + return nostr.Event{ + Kind: nostr.KindSimpleGroupMetadata, + CreatedAt: event.CreatedAt, + Tags: event.Tags, + } } diff --git a/zooid/instance.go b/zooid/instance.go index 9f4348c..2a163e1 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -2,12 +2,14 @@ package zooid import ( "context" + "slices" "iter" "log" "net/http" "sync" "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip29" "fiatjaf.com/nostr/eventstore" "fiatjaf.com/nostr/khatru" "github.com/gosimple/slug" @@ -19,7 +21,6 @@ type Instance struct { Secret nostr.SecretKey Events eventstore.Store Access *AccessStore - Groups *GroupsStore Blossom *BlossomStore Management *ManagementStore Relay *khatru.Relay @@ -57,12 +58,6 @@ func MakeInstance(hostname string) (*Instance, error) { Name: slug.Make(config.Self.Schema) + "_access", }, }, - Groups: &GroupsStore{ - Config: config, - Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "_groups", - }, - }, Blossom: &BlossomStore{ Config: config, Schema: &Schema{ @@ -110,10 +105,6 @@ func MakeInstance(hostname string) (*Instance, error) { log.Fatal("Failed to initialize access store:", err) } - if err := instance.Groups.Init(); err != nil { - log.Fatal("Failed to initialize groups store:", err) - } - if err := instance.Blossom.Init(); err != nil { log.Fatal("Failed to initialize blossom store:", err) } @@ -122,10 +113,6 @@ func MakeInstance(hostname string) (*Instance, error) { log.Fatal("Failed to initialize management store:", err) } - if config.Groups.Enabled { - instance.Groups.Enable(instance) - } - if config.Blossom.Enabled { instance.Blossom.Enable(instance) } @@ -163,7 +150,7 @@ func GetInstance(hostname string) (*Instance, error) { // Utility methods -func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { +func (instance *Instance) IsAdmin(pubkey nostr.PubKey) bool { if instance.Config.IsOwner(pubkey) { return true } @@ -172,9 +159,15 @@ func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { return true } - roles := instance.Config.GetRolesForPubkey(pubkey) + if instance.Config.CanManage(pubkey) { + return true + } - if instance.Config.CanManage(roles) { + return false +} + +func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { + if instance.IsAdmin(pubkey) { return true } @@ -185,35 +178,76 @@ func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { return false } -func (instance *Instance) GenerateInviteEvents(ctx context.Context, filter nostr.Filter) []*nostr.Event { - pubkey, ok := khatru.GetAuthed(ctx) +func (instance *Instance) IsGroupMember(id string, pubkey nostr.PubKey) bool { + filter := MakeGroupMembershipCheckFilter(id, pubkey) + events := instance.Events.QueryEvents(filter, 0) + isMember := CheckGroupMembership(events) - if !ok { - return []*nostr.Event{} + return isMember +} + +func (instance *Instance) HasGroupAccess(id string, pubkey nostr.PubKey) bool { + filter := MakeGroupMetadataFilter(id) + + for event := range instance.Events.QueryEvents(filter, 1) { + if !HasTag(event.Tags, "closed") { + return true + } } - var claim string + return instance.IsGroupMember(id, pubkey) +} - invites := instance.Access.GetInvitesByPubkey(pubkey) - - if len(invites) > 0 { - claim = First(invites).Claim - } else { - claim = RandomString(8) - instance.Access.AddInvite(pubkey, claim) +func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool { + // For zap receipts and gift wraps, authorize the recipient instead of the author. + // For everything else, make sure the authenticated user is the same as the event author + recipientAuthKinds := []nostr.Kind{ + nostr.KindZap, + nostr.KindGiftWrap, } - event := nostr.Event{ - Kind: AUTH_INVITE, - CreatedAt: nostr.Now(), - Tags: nostr.Tags{ - nostr.Tag{"claim", claim}, + if slices.Contains(recipientAuthKinds, event.Kind) { + recipientTag := event.Tags.Find("p") + + if recipientTag != nil { + pubkey, err := nostr.PubKeyFromHex(recipientTag[1]) + + if err == nil && instance.HasAccess(pubkey) { + return true + } + } + } + + return false +} + +func (instance *Instance) OnJoinEvent(event nostr.Event) (reject bool, msg string) { + claimTag := event.Tags.Find("claim") + + if claimTag == nil { + return true, "invalid: no claim tag" + } + + filter := nostr.Filter{ + Kinds: []nostr.Kind{AUTH_INVITE}, + Tags: nostr.TagMap{ + "claim": []string{claimTag[1]}, }, } - event.Sign(instance.Secret) + for range instance.Events.QueryEvents(filter, 1) { + return false, "" + } - return []*nostr.Event{&event} + return true, "invalid: failed to validate invite code" +} + +func (instance *Instance) GetGroupMetadataEvent(h string) nostr.Event { + for event := range instance.Events.QueryEvents(MakeGroupMetadataFilter(h), 1) { + return event + } + + return nostr.Event{} } // Handlers @@ -223,6 +257,78 @@ func (instance *Instance) OnConnect(ctx context.Context) { } func (instance *Instance) OnEvent(ctx context.Context, event nostr.Event) (reject bool, msg string) { + if instance.AllowRecipientEvent(event) { + return false, "" + } + + pubkey, isAuthenticated := khatru.GetAuthed(ctx) + + if !isAuthenticated { + return true, "auth-required: authentication is required for access" + } else if pubkey != event.PubKey { + return true, "restricted: you cannot publish events on behalf of others" + } + + if event.Kind == AUTH_JOIN { + return instance.OnJoinEvent(event) + } + + if !instance.HasAccess(pubkey) { + return true, "restricted: you are not a member of this relay" + } + + if slices.Contains(nip29.MetadataEventKinds, event.Kind) { + return true, "invalid: group metadata cannot be set directly" + } + + if slices.Contains(nip29.ModerationEventKinds, event.Kind) && !instance.IsAdmin(event.PubKey) { + return true, "restricted: you are not authorized to manage groups" + } + + allGroupKinds := append( + nip29.ModerationEventKinds, + nostr.KindSimpleGroupJoinRequest, + nostr.KindSimpleGroupLeaveRequest, + ) + + h := GetGroupIDFromEvent(event) + + if slices.Contains(allGroupKinds, event.Kind) { + if !instance.Config.Groups.Enabled { + return true, "invalid: group events not accepted on this relay" + } + + if h == "" { + return true, "invalid: h tag is required" + } + + meta := instance.GetGroupMetadataEvent(h) + + if event.Kind == nostr.KindSimpleGroupCreateGroup && !IsEmptyEvent(meta) { + return true, "invalid: that group already exists" + } else if IsEmptyEvent(meta) { + return true, "invalid: no such group exists" + } + + if event.Kind == nostr.KindSimpleGroupJoinRequest && instance.IsGroupMember(h, event.PubKey) { + return true, "duplicate: already a member" + } + + if event.Kind == nostr.KindSimpleGroupLeaveRequest && !instance.IsGroupMember(h, event.PubKey) { + return true, "duplicate: not currently a member" + } + } else if h != "" { + meta := instance.GetGroupMetadataEvent(h) + + if IsEmptyEvent(meta) { + return true, "invalid: no such group exists" + } + + if HasTag(meta.Tags, "closed") && !instance.IsGroupMember(h, pubkey) { + return true, "restricted: you are not a member of that group" + } + } + return false, "" } @@ -239,19 +345,133 @@ func (instance *Instance) DeleteEvent(ctx context.Context, id nostr.ID) error { } func (instance *Instance) OnEventSaved(ctx context.Context, event nostr.Event) { + addEvent := func(newEvent nostr.Event) { + if err := newEvent.Sign(instance.Secret); err != nil { + log.Println(err) + } else { + if err := instance.Events.SaveEvent(newEvent); err != nil { + log.Println(err) + } else { + instance.Relay.BroadcastEvent(newEvent) + } + } + } + + if event.Kind == nostr.KindSimpleGroupJoinRequest && instance.Config.Groups.AutoJoin { + h := GetGroupIDFromEvent(event) + meta := instance.GetGroupMetadataEvent(h) + + if !HasTag(meta.Tags, "closed") { + addEvent(MakePutUserEvent(h, event.PubKey)) + } + } + + if event.Kind == nostr.KindSimpleGroupLeaveRequest && instance.Config.Groups.AutoLeave { + addEvent(MakeRemoveUserEvent(GetGroupIDFromEvent(event), event.PubKey)) + } + + if event.Kind == nostr.KindSimpleGroupCreateGroup { + addEvent(MakeMetadataEvent(event)) + } + + if event.Kind == nostr.KindSimpleGroupEditMetadata { + addEvent(MakeMetadataEvent(event)) + } + + if event.Kind == nostr.KindSimpleGroupDeleteGroup { + for _, filter := range MakeGroupEventFilters(GetGroupIDFromEvent(event)) { + for event := range instance.Events.QueryEvents(filter, 0) { + instance.Events.DeleteEvent(event.ID) + } + } + } } func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Event) { } func (instance *Instance) OnRequest(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { + pubkey, ok := khatru.GetAuthed(ctx) + + if !ok { + return true, "auth-required: authentication is required for access" + } + + if !instance.HasAccess(pubkey) { + return true, "restricted: you are not a member of this relay" + } + return false, "" } func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter) iter.Seq[nostr.Event] { return func(yield func(nostr.Event) bool) { - for evt := range instance.Events.QueryEvents(filter, 400) { - if !yield(evt) { + pubkey, ok := khatru.GetAuthed(ctx) + + if !ok { + log.Fatal("Unauthenticated user was allowed to query events") + } + + stripSignature := func(event nostr.Event) nostr.Event { + if instance.Config.Policy.StripSignatures && !instance.IsAdmin(pubkey) { + var zeroSig [64]byte + event.Sig = zeroSig + } + + return event + } + + if slices.Contains(filter.Kinds, AUTH_INVITE) && instance.Config.CanInvite(pubkey) { + var claim string + + invites := instance.Access.GetInvitesByPubkey(pubkey) + + if len(invites) > 0 { + claim = First(invites).Claim + } else { + claim = RandomString(8) + instance.Access.AddInvite(pubkey, claim) + } + + event := nostr.Event{ + Kind: AUTH_INVITE, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + nostr.Tag{"claim", claim}, + }, + } + + event.Sign(instance.Secret) + + if !yield(stripSignature(event)) { + return + } + } + + for event := range instance.Events.QueryEvents(filter, 1000) { + hTag := event.Tags.Find("h") + + // Prune group related events if groups are disabled + if !instance.Config.Groups.Enabled { + if slices.Contains(nip29.ModerationEventKinds, event.Kind) { + continue + } + + if slices.Contains(nip29.MetadataEventKinds, event.Kind) { + continue + } + + if hTag != nil { + continue + } + } + + // Prune events that the user doesn't have access to + if hTag != nil && !instance.HasGroupAccess(hTag[1], pubkey) { + continue + } + + if !yield(event) { return } } diff --git a/zooid/management.go b/zooid/management.go index bfa27cc..79d4d3a 100644 --- a/zooid/management.go +++ b/zooid/management.go @@ -184,7 +184,7 @@ func (m *ManagementStore) Enable(instance *Instance) { instance.Relay.ManagementAPI.OnAPICall = func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string) { pubkey, ok := khatru.GetAuthed(ctx) - if ok && m.Config.CanManage(m.Config.GetRolesForPubkey(pubkey)) { + if ok && m.Config.CanManage(pubkey) { return true, "blocked: only relay admins can manage this relay." } @@ -196,7 +196,7 @@ func (m *ManagementStore) Enable(instance *Instance) { Authors: []nostr.PubKey{pubkey}, } - for event := range instance.Events.QueryEvents(filter, 1000000) { + for event := range instance.Events.QueryEvents(filter, 0) { instance.Events.DeleteEvent(event.ID) } @@ -229,7 +229,7 @@ func (m *ManagementStore) Enable(instance *Instance) { IDs: []nostr.ID{id}, } - for event := range instance.Events.QueryEvents(filter, 1000000) { + for event := range instance.Events.QueryEvents(filter, 0) { instance.Events.DeleteEvent(event.ID) } diff --git a/zooid/util.go b/zooid/util.go index b127ad9..86dfbe3 100644 --- a/zooid/util.go +++ b/zooid/util.go @@ -3,6 +3,7 @@ package zooid import ( "math/rand" "strings" + "fiatjaf.com/nostr" ) const ( @@ -59,3 +60,18 @@ func Split(s string, delim string) []string { return strings.Split(s, delim) } } + +func HasTag(tags nostr.Tags, key string) bool { + for _, v := range tags { + if len(v) >= 1 && v[0] == key { + return true + } + } + return false +} + +func IsEmptyEvent(event nostr.Event) bool { + var zeroID nostr.ID + + return event.ID == zeroID +}