diff --git a/zooid/events.go b/zooid/events.go index 61240ac..8ef019b 100644 --- a/zooid/events.go +++ b/zooid/events.go @@ -5,15 +5,16 @@ import ( "encoding/json" "fmt" "iter" - "log" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore" + "fiatjaf.com/nostr/khatru" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" ) type EventStore struct { + Relay *khatru.Relay Config *Config Schema *Schema FTSAvailable bool @@ -337,6 +338,22 @@ func (events *EventStore) CountEvents(filter nostr.Filter) (uint32, error) { // Non-eventstore methods +func (events *EventStore) SignAndSaveEvent(event nostr.Event, broadcast bool) error { + if err := events.Config.Sign(&event); err != nil { + return err + } + + if err := events.SaveEvent(event); err != nil { + return err + } + + if broadcast { + events.Relay.BroadcastEvent(event) + } + + return nil +} + func (events *EventStore) GetOrCreateApplicationSpecificData(d string) nostr.Event { filter := nostr.Filter{ Kinds: []nostr.Kind{nostr.KindApplicationSpecificData}, @@ -349,19 +366,29 @@ func (events *EventStore) GetOrCreateApplicationSpecificData(d string) nostr.Eve return event } - event := nostr.Event{ + return nostr.Event{ Kind: nostr.KindApplicationSpecificData, CreatedAt: nostr.Now(), Tags: nostr.Tags{ []string{"d", d}, }, } +} - if err := events.Config.Sign(&event); err != nil { - log.Println("Failed to sign application specific event: %w", err) +func (events *EventStore) GetOrCreateMemberList() nostr.Event { + filter := nostr.Filter{ + Kinds: []nostr.Kind{RELAY_MEMBERS}, } - events.SaveEvent(event) + for event := range events.QueryEvents(filter, 1) { + return event + } - return event + return nostr.Event{ + Kind: nostr.KindApplicationSpecificData, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + []string{"-"}, + }, + } } diff --git a/zooid/instance.go b/zooid/instance.go index ea0dff4..8a57852 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -9,18 +9,17 @@ import ( "strings" "fiatjaf.com/nostr" - "fiatjaf.com/nostr/eventstore" "fiatjaf.com/nostr/khatru" "fiatjaf.com/nostr/nip29" "github.com/gosimple/slug" ) type Instance struct { + Relay *khatru.Relay Config *Config - Events eventstore.Store + Events *EventStore Blossom *BlossomStore Management *ManagementStore - Relay *khatru.Relay } func MakeInstance(filename string) (*Instance, error) { @@ -29,7 +28,10 @@ func MakeInstance(filename string) (*Instance, error) { return nil, err } + relay := khatru.NewRelay() + events := &EventStore{ + Relay: relay, Config: config, Schema: &Schema{ Name: slug.Make(config.Schema), @@ -47,11 +49,11 @@ func MakeInstance(filename string) (*Instance, error) { } instance := &Instance{ + Relay: relay, Config: config, Events: events, Blossom: blossom, Management: management, - Relay: khatru.NewRelay(), } // NIP 11 info @@ -127,10 +129,6 @@ func (instance *Instance) Cleanup() { // Utility methods -func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { - return instance.Management.PubkeyIsAllowed(pubkey) -} - func (instance *Instance) IsGroupMember(id string, pubkey nostr.PubKey) bool { filter := MakeGroupMembershipCheckFilter(id, pubkey) events := instance.Events.QueryEvents(filter, 0) @@ -177,7 +175,7 @@ func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool { if recipientTag != nil { pubkey, err := nostr.PubKeyFromHex(recipientTag[1]) - if err == nil && instance.HasAccess(pubkey) { + if err == nil && instance.Management.IsPubkeyAllowed(pubkey) { return true } } @@ -188,7 +186,7 @@ func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool { func (instance *Instance) GenerateInviteEvent(pubkey nostr.PubKey) nostr.Event { filter := nostr.Filter{ - Kinds: []nostr.Kind{AUTH_INVITE}, + Kinds: []nostr.Kind{RELAY_INVITE}, Authors: []nostr.PubKey{pubkey}, } @@ -197,7 +195,7 @@ func (instance *Instance) GenerateInviteEvent(pubkey nostr.PubKey) nostr.Event { } event := nostr.Event{ - Kind: AUTH_INVITE, + Kind: RELAY_INVITE, CreatedAt: nostr.Now(), Tags: nostr.Tags{ []string{"claim", RandomString(8)}, @@ -205,14 +203,10 @@ func (instance *Instance) GenerateInviteEvent(pubkey nostr.PubKey) nostr.Event { }, } - if err := instance.Config.Sign(&event); err != nil { + if err := instance.Events.SignAndSaveEvent(event, false); err != nil { log.Printf("Failed to sign invite event: %v", err) } - if err := instance.Events.SaveEvent(event); err != nil { - log.Printf("Failed to save invite event: %v", err) - } - return event } @@ -224,7 +218,7 @@ func (instance *Instance) OnJoinEvent(event nostr.Event) (reject bool, msg strin } filter := nostr.Filter{ - Kinds: []nostr.Kind{AUTH_INVITE}, + Kinds: []nostr.Kind{RELAY_INVITE}, } for event := range instance.Events.QueryEvents(filter, 0) { @@ -263,11 +257,11 @@ func (instance *Instance) OnEvent(ctx context.Context, event nostr.Event) (rejec return true, "restricted: you cannot publish events on behalf of others" } - if event.Kind == AUTH_JOIN { + if event.Kind == RELAY_JOIN { return instance.OnJoinEvent(event) } - if !instance.HasAccess(pubkey) { + if !instance.Management.IsPubkeyAllowed(pubkey) { return true, "restricted: you are not a member of this relay" } @@ -350,17 +344,19 @@ 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 := instance.Config.Sign(&newEvent); err != nil { + if err := instance.Events.SignAndSaveEvent(newEvent, true); 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 == RELAY_JOIN { + instance.Management.AllowPubkey(event.PubKey) + } + + if event.Kind == RELAY_LEAVE { + instance.Management.BanPubkey(event.PubKey, "exited relay") + } + if event.Kind == nostr.KindSimpleGroupJoinRequest && instance.Config.Groups.AutoJoin { h := GetGroupIDFromEvent(event) meta := instance.GetGroupMetadataEvent(h) @@ -392,7 +388,7 @@ func (instance *Instance) OnEventSaved(ctx context.Context, event nostr.Event) { } func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Event) { - if slices.Contains([]nostr.Kind{AUTH_INVITE, AUTH_JOIN}, event.Kind) { + if slices.Contains([]nostr.Kind{RELAY_INVITE, RELAY_JOIN}, event.Kind) { instance.Events.SaveEvent(event) } } @@ -404,7 +400,7 @@ func (instance *Instance) OnRequest(ctx context.Context, filter nostr.Filter) (r return true, "auth-required: authentication is required for access" } - if !instance.HasAccess(pubkey) { + if !instance.Management.IsPubkeyAllowed(pubkey) { return true, "restricted: you are not a member of this relay" } @@ -435,7 +431,7 @@ func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter) return event } - if slices.Contains(filter.Kinds, AUTH_INVITE) && instance.Config.CanInvite(pubkey) { + if slices.Contains(filter.Kinds, RELAY_INVITE) && instance.Config.CanInvite(pubkey) { if !yield(stripSignature(instance.GenerateInviteEvent(pubkey))) { return } @@ -476,5 +472,5 @@ func (instance *Instance) RejectConnection(r *http.Request) bool { } func (instance *Instance) PreventBroadcast(ws *khatru.WebSocket, event nostr.Event) bool { - return event.Kind == AUTH_JOIN + return event.Kind == RELAY_JOIN } diff --git a/zooid/instance_test.go b/zooid/instance_test.go index dfbe659..bdb0281 100644 --- a/zooid/instance_test.go +++ b/zooid/instance_test.go @@ -102,7 +102,7 @@ func TestInstance_HasAccess(t *testing.T) { // Add a join event for the user (must be signed by the user) joinEvent := nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, CreatedAt: nostr.Now(), PubKey: userPubkey, Tags: nostr.Tags{{"claim", "test"}}, @@ -211,7 +211,7 @@ func TestInstance_AllowRecipientEvent(t *testing.T) { // Add user access joinEvent := nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, CreatedAt: nostr.Now(), PubKey: userPubkey, Tags: nostr.Tags{{"claim", "test"}}, @@ -285,8 +285,8 @@ func TestInstance_GenerateInviteEvent(t *testing.T) { inviteEvent := instance.GenerateInviteEvent(userPubkey) // Test event properties - if inviteEvent.Kind != AUTH_INVITE { - t.Errorf("GenerateInviteEvent() kind = %v, want %v", inviteEvent.Kind, AUTH_INVITE) + if inviteEvent.Kind != RELAY_INVITE { + t.Errorf("GenerateInviteEvent() kind = %v, want %v", inviteEvent.Kind, RELAY_INVITE) } if inviteEvent.PubKey != instance.Config.Secret.Public() { @@ -332,7 +332,7 @@ func TestInstance_OnJoinEvent(t *testing.T) { { name: "valid join event", joinEvent: nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, Tags: nostr.Tags{{"claim", claimTag[1]}}, }, wantReject: false, @@ -341,7 +341,7 @@ func TestInstance_OnJoinEvent(t *testing.T) { { name: "join event without claim", joinEvent: nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, Tags: nostr.Tags{}, }, wantReject: true, @@ -350,7 +350,7 @@ func TestInstance_OnJoinEvent(t *testing.T) { { name: "join event with invalid claim", joinEvent: nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, Tags: nostr.Tags{{"claim", "invalid-claim"}}, }, wantReject: true, @@ -476,7 +476,7 @@ func TestInstance_HasAccess_WithBannedUser(t *testing.T) { // Test banned user has no access even with join event joinEvent := nostr.Event{ - Kind: AUTH_JOIN, + Kind: RELAY_JOIN, CreatedAt: nostr.Now(), PubKey: userPubkey, Tags: nostr.Tags{{"claim", "test"}}, diff --git a/zooid/management.go b/zooid/management.go index ad6a5ac..4d0036f 100644 --- a/zooid/management.go +++ b/zooid/management.go @@ -8,12 +8,26 @@ import ( "fmt" ) +// Management store takes care of all nip 86 methods, as well as defining actions for internal use. +// +// The banned pubkeys list is a NIP 78 application-specific event, which keeps track of which pubkeys +// have been banned, independently of the members list. Banned events works the same way. +// +// Membership is implemented as defined here https://github.com/nostr-protocol/nips/pull/1079/files, using +// both membership lists and add/remove events. +// +// Actions like BanPubkey and AllowPubkey synchronize ban and membership lists. These should be called in most +// cases, unless you're trying to do something more advanced. +// +// All actions are idempotent, and won't do anything if conditions are already correct. + type ManagementStore struct { + Relay *khatru.Relay Config *Config Events *EventStore } -// Banned pubkeys +// Internal banned pubkeys list func (m *ManagementStore) GetBannedPubkeyItems() []nip86.PubKeyReason { event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) @@ -29,30 +43,135 @@ func (m *ManagementStore) GetBannedPubkeyItems() []nip86.PubKeyReason { return items } -func (m *ManagementStore) GetBannedPubkeys() []nostr.PubKey { +func (m *ManagementStore) AddBannedPubkey(pubkey nostr.PubKey, reason string) error { + event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) + + if event.Tags.FindWithValue("pubkey", pubkey.Hex()) == nil { + event.Tags = append(event.Tags, nostr.Tag{"pubkey", pubkey.Hex(), reason}) + + if err := m.Events.SignAndSaveEvent(event, false); err != nil { + return err + } + } + + return nil +} + +func (m *ManagementStore) RemoveBannedPubkey(pubkey nostr.PubKey) error { + event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) + + if event.Tags.FindWithValue("pubkey", pubkey.Hex()) != nil { + event.Tags = Filter(event.Tags, func(t nostr.Tag) bool { + return t[1] != pubkey.Hex() + }) + + if err := m.Events.SignAndSaveEvent(event, false); err != nil { + return err + } + } + + return nil +} + +// Membership + +func (m *ManagementStore) GetMembers() []nostr.PubKey { pubkeys := make([]nostr.PubKey, 0) - for _, item := range m.GetBannedPubkeyItems() { - pubkeys = append(pubkeys, item.PubKey) + for tag := range m.Events.GetOrCreateMemberList().Tags.FindAll("member") { + pubkey, err := nostr.PubKeyFromHex(tag[1]) + + if err == nil { + pubkeys = append(pubkeys, pubkey) + } } return pubkeys } +func (m *ManagementStore) IsMember(pubkey nostr.PubKey) bool { + return m.Events.GetOrCreateMemberList().Tags.FindWithValue("member", pubkey.Hex()) != nil +} + +func (m *ManagementStore) AddMember(pubkey nostr.PubKey) error { + membersEvent := m.Events.GetOrCreateMemberList() + + if membersEvent.Tags.FindWithValue("member", pubkey.Hex()) == nil { + addMemberEvent := nostr.Event{ + Kind: RELAY_ADD_MEMBER, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + []string{"-"}, + []string{"p", pubkey.Hex()}, + }, + } + + if err := m.Events.SignAndSaveEvent(addMemberEvent, true); err != nil { + return err + } + + membersEvent.Tags = append(membersEvent.Tags, nostr.Tag{"pubkey", pubkey.Hex()}) + + if err := m.Events.SignAndSaveEvent(membersEvent, true); err != nil { + return err + } + } + + return nil +} + +func (m *ManagementStore) RemoveMember(pubkey nostr.PubKey) error { + membersEvent := m.Events.GetOrCreateMemberList() + + if membersEvent.Tags.FindWithValue("member", pubkey.Hex()) != nil { + removeMemberEvent := nostr.Event{ + Kind: RELAY_REMOVE_MEMBER, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + []string{"-"}, + []string{"p", pubkey.Hex()}, + }, + } + + if err := m.Events.SignAndSaveEvent(removeMemberEvent, true); err != nil { + return err + } + + membersEvent.Tags = Filter(membersEvent.Tags, func(t nostr.Tag) bool { + return t[1] != pubkey.Hex() + }) + + if err := m.Events.SignAndSaveEvent(membersEvent, true); err != nil { + return err + } + + } + + return nil +} + +// Banning + func (m *ManagementStore) BanPubkey(pubkey nostr.PubKey, reason string) error { - event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) - event.Tags = append(event.Tags, nostr.Tag{"pubkey", pubkey.Hex(), reason}) + if err := m.RemoveMember(pubkey); err != nil { + return err + } - return m.Events.SaveEvent(event) + if err := m.AddBannedPubkey(pubkey, reason); err != nil { + return err + } + + filter := nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + } + + for event := range m.Events.QueryEvents(filter, 0) { + m.Events.DeleteEvent(event.ID) + } + + return nil } -func (m *ManagementStore) PubkeyIsBanned(pubkey nostr.PubKey) bool { - event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) - tag := event.Tags.FindWithValue("pubkey", pubkey.Hex()) - - return tag != nil -} - -// Allowed pubkeys +// Allowing func (m *ManagementStore) GetAllowedPubkeyItems() []nip86.PubKeyReason { reasons := make([]nip86.PubKeyReason, 0) @@ -71,47 +190,29 @@ func (m *ManagementStore) GetAllowedPubkeyItems() []nip86.PubKeyReason { for _, pubkey := range role.Pubkeys { reasons = append(reasons, nip86.PubKeyReason{ PubKey: nostr.MustPubKeyFromHex(pubkey), - Reason: fmt.Sprintf("assigned to role: %s", name), + Reason: fmt.Sprintf("assigned to %s role", name), }) } } - filter := nostr.Filter{ - Kinds: []nostr.Kind{AUTH_JOIN}, - } + for tag := range m.Events.GetOrCreateMemberList().Tags.FindAll("member") { + pubkey, err := nostr.PubKeyFromHex(tag[1]) - for event := range m.Events.QueryEvents(filter, 0) { - reasons = append( - reasons, - nip86.PubKeyReason{ - PubKey: event.PubKey, - Reason: "joined via invite code", - }, - ) + if err != nil { + reasons = append( + reasons, + nip86.PubKeyReason{ + PubKey: pubkey, + Reason: "relay member", + }, + ) + } } return reasons } -func (m *ManagementStore) GetAllowedPubkeys() []nostr.PubKey { - pubkeys := make([]nostr.PubKey, 0) - for _, item := range m.GetAllowedPubkeyItems() { - pubkeys = append(pubkeys, item.PubKey) - } - - return pubkeys -} - -func (m *ManagementStore) AllowPubkey(pubkey nostr.PubKey, reason string) error { - event := m.Events.GetOrCreateApplicationSpecificData(BANNED_PUBKEYS) - event.Tags = Filter(event.Tags, func(t nostr.Tag) bool { - return t[1] != pubkey.Hex() - }) - - return m.Events.SaveEvent(event) -} - -func (m *ManagementStore) PubkeyIsAllowed(pubkey nostr.PubKey) bool { +func (m *ManagementStore) IsPubkeyAllowed(pubkey nostr.PubKey) bool { if m.Config.IsOwner(pubkey) || m.Config.IsSelf(pubkey) { return true } @@ -120,32 +221,32 @@ func (m *ManagementStore) PubkeyIsAllowed(pubkey nostr.PubKey) bool { return true } - filter := nostr.Filter{ - Kinds: []nostr.Kind{AUTH_JOIN}, - Authors: []nostr.PubKey{pubkey}, + return m.IsMember(pubkey) +} + +func (m *ManagementStore) AllowPubkey(pubkey nostr.PubKey) error { + if m.IsPubkeyAllowed(pubkey) { + return nil } - for range m.Events.QueryEvents(filter, 1) { - return true + if err := m.AddMember(pubkey); err != nil { + return err } - return false + if err := m.RemoveBannedPubkey(pubkey); err != nil { + return err + } + + return nil } // Banned events -type BannedEventItem struct { - ID nostr.ID - Reason string -} - -func (m *ManagementStore) GetBannedEventItems() []BannedEventItem { - event := m.Events.GetOrCreateApplicationSpecificData(BANNED_EVENTS) - - items := make([]BannedEventItem, 0) - for tag := range event.Tags.FindAll("event") { - items = append(items, BannedEventItem{ - ID: nostr.MustIDFromHex(tag[1]), +func (m *ManagementStore) GetBannedEventItems() []nip86.IDReason { + items := make([]nip86.IDReason, 0) + for tag := range m.Events.GetOrCreateApplicationSpecificData(BANNED_EVENTS).Tags.FindAll("event") { + items = append(items, nip86.IDReason{ + ID: tag[1], Reason: tag[2], }) } @@ -153,20 +254,15 @@ func (m *ManagementStore) GetBannedEventItems() []BannedEventItem { return items } -func (m *ManagementStore) GetBannedEvents() []nostr.ID { - ids := make([]nostr.ID, 0) - for _, item := range m.GetBannedEventItems() { - ids = append(ids, item.ID) +func (m *ManagementStore) BanEvent(id nostr.ID, reason string) error { + if err := m.Events.DeleteEvent(id); err != nil { + return err } - return ids -} - -func (m *ManagementStore) BanEvent(id nostr.ID, reason string) error { event := m.Events.GetOrCreateApplicationSpecificData(BANNED_EVENTS) event.Tags = append(event.Tags, nostr.Tag{"event", id.Hex(), reason}) - return m.Events.SaveEvent(event) + return m.Events.SignAndSaveEvent(event, false) } func (m *ManagementStore) AllowEvent(id nostr.ID, reason string) error { @@ -175,7 +271,7 @@ func (m *ManagementStore) AllowEvent(id nostr.ID, reason string) error { return t[1] == id.Hex() }) - return m.Events.SaveEvent(event) + return m.Events.SignAndSaveEvent(event, false) } func (m *ManagementStore) EventIsBanned(id nostr.ID) bool { @@ -199,19 +295,11 @@ func (m *ManagementStore) Enable(instance *Instance) { } 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, 0) { - 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) + return m.AllowPubkey(pubkey) } instance.Relay.ManagementAPI.ListBannedPubKeys = func(ctx context.Context) ([]nip86.PubKeyReason, error) { @@ -223,8 +311,6 @@ func (m *ManagementStore) Enable(instance *Instance) { } instance.Relay.ManagementAPI.BanEvent = func(ctx context.Context, id nostr.ID, reason string) error { - instance.Events.DeleteEvent(id) - return m.BanEvent(id, reason) } @@ -233,17 +319,6 @@ func (m *ManagementStore) Enable(instance *Instance) { } instance.Relay.ManagementAPI.ListBannedEvents = func(ctx context.Context) ([]nip86.IDReason, error) { - reasons := make([]nip86.IDReason, 0) - for _, item := range m.GetBannedEventItems() { - reasons = append( - reasons, - nip86.IDReason{ - ID: item.ID.Hex(), - Reason: item.Reason, - }, - ) - } - - return reasons, nil + return m.GetBannedEventItems(), nil } } diff --git a/zooid/util.go b/zooid/util.go index 81f5185..7e913d2 100644 --- a/zooid/util.go +++ b/zooid/util.go @@ -7,10 +7,14 @@ import ( ) const ( - AUTH_JOIN = 28934 - AUTH_INVITE = 28935 - BANNED_PUBKEYS = "zooid/banned_pubkeys" - BANNED_EVENTS = "zooid/banned_events" + RELAY_ADD_MEMBER = 8000 + RELAY_REMOVE_MEMBER = 8001 + RELAY_MEMBERS = 13534 + RELAY_JOIN = 28934 + RELAY_INVITE = 28935 + RELAY_LEAVE = 28936 + BANNED_PUBKEYS = "zooid/banned_pubkeys" + BANNED_EVENTS = "zooid/banned_events" ) func First[T any](s []T) T {