diff --git a/README.md b/README.md index 49a1fb4..9ed5933 100644 --- a/README.md +++ b/README.md @@ -99,5 +99,7 @@ See `justfile` for defined commands. ## TODO +- [ ] Sync claims to management db, pull directly from management db when checking access +- [ ] Add admin/owner/etc to list allowed pubkeys - [ ] Watch configuration files and hot reload - [ ] Free up resources after instance inactivity diff --git a/zooid/instance.go b/zooid/instance.go index 7952a1e..13587b3 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -14,12 +14,13 @@ import ( ) type Instance struct { - Host string - Config *Config - Secret nostr.SecretKey - Events eventstore.Store - Access *AccessStore - Relay *khatru.Relay + Host string + Config *Config + Secret nostr.SecretKey + Events eventstore.Store + Access *AccessStore + Management *ManagementStore + Relay *khatru.Relay } func MakeInstance(hostname string) (*Instance, error) { @@ -51,7 +52,13 @@ func MakeInstance(hostname string) (*Instance, error) { Access: &AccessStore{ Config: config, Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "__events", + Name: slug.Make(config.Self.Schema) + "__access", + }, + }, + Management: &ManagementStore{ + Config: config, + Schema: &Schema{ + Name: slug.Make(config.Self.Schema) + "__management", }, }, Relay: khatru.NewRelay(), @@ -79,6 +86,20 @@ func MakeInstance(hostname string) (*Instance, error) { instance.Relay.RejectConnection = instance.RejectConnection instance.Relay.PreventBroadcast = instance.PreventBroadcast + // Initialize stuff + + if err := instance.Events.Init(); err != nil { + log.Fatal("Failed to initialize event store:", err) + } + + if err := instance.Access.Init(); err != nil { + log.Fatal("Failed to initialize access store:", err) + } + + if err := instance.Management.Init(); err != nil { + log.Fatal("Failed to initialize management store:", err) + } + if config.Groups.Enabled { EnableGroups(instance) } @@ -88,17 +109,7 @@ func MakeInstance(hostname string) (*Instance, error) { } if config.Management.Enabled { - EnableManagement(instance) - } - - // Initialize stuff - - if err := instance.Events.Init(); err != nil { - log.Fatal("Failed to initialize event store:", err) - } - - if err := instance.Access.Init(); err != nil { - log.Fatal("Failed to initialize access store:", err) + instance.Management.Enable(instance) } return instance, nil diff --git a/zooid/management.go b/zooid/management.go index 5afa365..a242a12 100644 --- a/zooid/management.go +++ b/zooid/management.go @@ -1,4 +1,261 @@ package zooid -func EnableManagement(instance *Instance) { +import ( + "context" + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/khatru" + "fiatjaf.com/nostr/nip86" + "fmt" + "github.com/Masterminds/squirrel" +) + +type ManagementStore struct { + Config *Config + Schema *Schema +} + +func (m *ManagementStore) Init() error { + basicSchema := m.Schema.Render(` + CREATE TABLE IF NOT EXISTS {{.Name}}__pubkeys ( + pubkey PRIMARY KEY NOT NULL, + status TEXT NOT NULL, + reason TEXT + ); + + CREATE INDEX IF NOT EXISTS {{.Name}}__idx_pubkeys_pubkey ON {{.Name}}__pubkeys(pubkey); + CREATE INDEX IF NOT EXISTS {{.Name}}__idx_pubkeys_status ON {{.Name}}__pubkeys(status); + + CREATE TABLE IF NOT EXISTS {{.Name}}__events ( + id PRIMARY KEY NOT NULL, + status TEXT NOT NULL, + reason TEXT + ); + + CREATE INDEX IF NOT EXISTS {{.Name}}__idx_events_id ON {{.Name}}__events(id); + CREATE INDEX IF NOT EXISTS {{.Name}}__idx_events_status ON {{.Name}}__events(status); + `) + + if _, err := GetDb().Exec(basicSchema); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + return nil +} + +// Banned/allowed pubkeys + +type Nip86PubkeyInfo struct { + Pubkey nostr.PubKey + Status string + Reason string +} + +func (m *ManagementStore) SelectPubkeys() squirrel.SelectBuilder { + return squirrel.Select("pubkey", "status", "reason").From(m.Schema.Prefix("pubkeys")) +} + +func (m *ManagementStore) QueryPubkeys(builder squirrel.SelectBuilder) []Nip86PubkeyInfo { + rows, err := builder.RunWith(GetDb()).Query() + if err != nil { + return []Nip86PubkeyInfo{} + } + defer rows.Close() + + var items []Nip86PubkeyInfo + for rows.Next() { + var item Nip86PubkeyInfo + var pubkeyStr string + err := rows.Scan(&pubkeyStr, &item.Status) + if err != nil { + continue + } + + if pubkey, err := nostr.PubKeyFromHex(pubkeyStr); err == nil { + item.Pubkey = pubkey + } else { + continue + } + + items = append(items, item) + } + + return items +} + +func (m *ManagementStore) BanPubkey(pubkey nostr.PubKey, reason string) error { + _, err := squirrel.Insert(m.Schema.Prefix("pubkeys")). + Columns("pubkey", "status", "reason"). + Values(pubkey.Hex(), "banned", reason). + Suffix("ON CONFLICT(pubkey) DO UPDATE SET status = excluded.status, reason = excluded.reason"). + RunWith(GetDb()).Exec() + return err +} + +func (m *ManagementStore) AllowPubkey(pubkey nostr.PubKey, reason string) error { + _, err := squirrel.Delete(m.Schema.Prefix("pubkeys")). + Where(squirrel.Eq{"pubkey": pubkey.Hex()}). + RunWith(GetDb()).Exec() + return err +} + +func (m *ManagementStore) PubkeyHasStatus(pubkey nostr.PubKey, status string) bool { + builder := m.SelectPubkeys().Where(squirrel.Eq{"pubkey": pubkey.Hex()}) + + for _, item := range m.QueryPubkeys(builder) { + if item.Status == status { + return true + } + } + + return false +} + +// Banned/allowed events + +type Nip86EventInfo struct { + ID nostr.ID + Status string + Reason string +} + +func (m *ManagementStore) SelectEvents() squirrel.SelectBuilder { + return squirrel.Select("id", "status", "reason").From(m.Schema.Prefix("events")) +} + +func (m *ManagementStore) QueryEvents(builder squirrel.SelectBuilder) []Nip86EventInfo { + rows, err := builder.RunWith(GetDb()).Query() + if err != nil { + return []Nip86EventInfo{} + } + defer rows.Close() + + var items []Nip86EventInfo + for rows.Next() { + var item Nip86EventInfo + var idStr string + err := rows.Scan(&idStr, &item.Status, &item.Reason) + if err != nil { + continue + } + + if id, err := nostr.IDFromHex(idStr); err == nil { + item.ID = id + } else { + continue + } + + items = append(items, item) + } + + return items +} + +func (m *ManagementStore) BanEvent(id nostr.ID, reason string) error { + _, err := squirrel.Insert(m.Schema.Prefix("events")). + Columns("id", "status", "reason"). + Values(id.Hex(), "banned", reason). + Suffix("ON CONFLICT(id) DO UPDATE SET status = excluded.status, reason = excluded.reason"). + RunWith(GetDb()).Exec() + return err +} + +func (m *ManagementStore) AllowEvent(id nostr.ID, reason string) error { + _, err := squirrel.Delete(m.Schema.Prefix("events")). + Where(squirrel.Eq{"id": id.Hex()}). + RunWith(GetDb()).Exec() + return err +} + +func (m *ManagementStore) EventHasStatus(id nostr.ID, status string) bool { + builder := m.SelectEvents().Where(squirrel.Eq{"id": id.Hex()}) + + for _, item := range m.QueryEvents(builder) { + if item.Status == status { + return true + } + } + + return false +} + +// Handlers + +// Middleware + +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)) { + return true, "blocked: only relay admins can manage this relay." + } + + return false, "" + } + + instance.Relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey nostr.PubKey, reason string) error { + filter := nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + } + + for event := range instance.Events.QueryEvents(filter, 1000000) { + instance.Events.DeleteEvent(event.ID) + } + + return m.BanPubkey(pubkey, reason) + } + + instance.Relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey nostr.PubKey, reason string) error { + return m.AllowPubkey(pubkey, reason) + } + + instance.Relay.ManagementAPI.ListBannedPubKeys = func(ctx context.Context) ([]nip86.PubKeyReason, error) { + items := m.QueryPubkeys(m.SelectPubkeys().Where(squirrel.Eq{"status": "banned"})) + reasons := make([]nip86.PubKeyReason, 0, len(items)) + + for _, item := range items { + reasons = append( + reasons, + nip86.PubKeyReason{ + PubKey: item.Pubkey, + Reason: item.Reason, + }, + ) + } + + return reasons, nil + } + + instance.Relay.ManagementAPI.BanEvent = func(ctx context.Context, id nostr.ID, reason string) error { + filter := nostr.Filter{ + IDs: []nostr.ID{id}, + } + + for event := range instance.Events.QueryEvents(filter, 1000000) { + instance.Events.DeleteEvent(event.ID) + } + + return m.BanEvent(id, reason) + } + + instance.Relay.ManagementAPI.AllowEvent = func(ctx context.Context, id nostr.ID, reason string) error { + return m.AllowEvent(id, reason) + } + + instance.Relay.ManagementAPI.ListBannedEvents = func(ctx context.Context) ([]nip86.IDReason, error) { + items := m.QueryEvents(m.SelectEvents().Where(squirrel.Eq{"status": "banned"})) + reasons := make([]nip86.IDReason, 0, len(items)) + + for _, item := range items { + reasons = append( + reasons, + nip86.IDReason{ + ID: item.ID.Hex(), + Reason: item.Reason, + }, + ) + } + + return reasons, nil + } }