diff --git a/README.md b/README.md index 9ed5933..09289f3 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ See `justfile` for defined commands. ## TODO -- [ ] Sync claims to management db, pull directly from management db when checking access +- [ ] See if we can build groups directly on top of the event store by generating events eagerly rather than lazily - [ ] Add admin/owner/etc to list allowed pubkeys - [ ] Watch configuration files and hot reload - [ ] Free up resources after instance inactivity diff --git a/zooid/config.go b/zooid/config.go index c05db10..2ef400a 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -15,6 +15,7 @@ type Role struct { } type Config struct { + Host string Self struct { Name string `toml:"name"` Icon string `toml:"icon"` @@ -50,6 +51,8 @@ func LoadConfig(hostname string) (*Config, error) { return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) } + config.Host = hostname + return &config, nil } diff --git a/zooid/groups.go b/zooid/groups.go index 2603bc4..f0b5559 100644 --- a/zooid/groups.go +++ b/zooid/groups.go @@ -1,5 +1,320 @@ package zooid -func EnableGroups(instance *Instance) { - instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, 29) +import ( + "context" + "encoding/json" + "fmt" + "log" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip29" +) + +type GroupsStore struct { + Host string + Config *Config + Schema *Schema +} + +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) + } + + return nil +} + +// 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}, + Tags: nostr.TagMap{ + "p": []string{pubkey}, + "h": []string{id}, + }, + } + + events, err := GetBackend().QueryEvents(ctx, filter) + + if err != nil { + log.Println(err) + } + + for event := range events { + if event.Kind == nostr.KindSimpleGroupPutUser { + return true + } + + if event.Kind == nostr.KindSimpleGroupRemoveUser { + return false + } + } + + 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{ + Kind: nostr.KindSimpleGroupPutUser, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + nostr.Tag{"p", event.PubKey}, + nostr.Tag{"h", GetGroupIDFromEvent(event)}, + }, + } + + if err := putUser.Sign(RELAY_SECRET); err != nil { + log.Println(err) + } + + return &putUser +} + +func MakeRemoveUserEvent(event *nostr.Event) *nostr.Event { + removeUser := nostr.Event{ + Kind: nostr.KindSimpleGroupRemoveUser, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + nostr.Tag{"p", event.PubKey}, + nostr.Tag{"h", GetGroupIDFromEvent(event)}, + }, + } + + if err := removeUser.Sign(RELAY_SECRET); err != nil { + log.Println(err) + } + + return &removeUser } diff --git a/zooid/instance.go b/zooid/instance.go index ad1b97c..9f4348c 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -19,6 +19,7 @@ type Instance struct { Secret nostr.SecretKey Events eventstore.Store Access *AccessStore + Groups *GroupsStore Blossom *BlossomStore Management *ManagementStore Relay *khatru.Relay @@ -47,25 +48,31 @@ func MakeInstance(hostname string) (*Instance, error) { Events: &EventStore{ Config: config, Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "__events", + Name: slug.Make(config.Self.Schema) + "_events", }, }, Access: &AccessStore{ Config: config, Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "__access", + 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{ - Name: slug.Make(config.Self.Schema) + "__blossom", + Name: slug.Make(config.Self.Schema) + "_blossom", }, }, Management: &ManagementStore{ Config: config, Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "__management", + Name: slug.Make(config.Self.Schema) + "_management", }, }, Relay: khatru.NewRelay(), @@ -103,6 +110,10 @@ 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) } @@ -112,7 +123,7 @@ func MakeInstance(hostname string) (*Instance, error) { } if config.Groups.Enabled { - EnableGroups(instance) + instance.Groups.Enable(instance) } if config.Blossom.Enabled { diff --git a/zooid/management.go b/zooid/management.go index a242a12..bfa27cc 100644 --- a/zooid/management.go +++ b/zooid/management.go @@ -178,8 +178,6 @@ func (m *ManagementStore) EventHasStatus(id nostr.ID, status string) bool { return false } -// Handlers - // Middleware func (m *ManagementStore) Enable(instance *Instance) {