diff --git a/event_policy.go b/event_policy.go index 73f131a..eca0a2b 100644 --- a/event_policy.go +++ b/event_policy.go @@ -36,13 +36,28 @@ func (s *State) RestrictWritesBasedOnGroupRules(ctx context.Context, event *nost group := s.GetGroupFromEvent(event) 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, "duplicate: already a member" } + + // Check if group is closed and validate invite code + if group.Closed { + codeTag := event.Tags.GetFirst([]string{"code", ""}) + if codeTag == nil { + return true, "group is closed, invite code required" + } + + code := (*codeTag)[1] + inviteCode, exists := s.GetValidInviteCode(code) + if !exists || inviteCode.GroupID != group.Address.ID { + return true, "invalid invite code" + } + } + return false, "" } @@ -191,7 +206,7 @@ func (s *State) ApplyModerationAction(ctx context.Context, event *nostr.Event) { // apply the moderation action group.mu.Lock() - action.Apply(&group.Group) + action.Apply(&group.Group, s) group.mu.Unlock() // if it's a delete event we have to actually delete stuff from the database here @@ -256,18 +271,17 @@ func (s *State) ReactToJoinRequest(ctx context.Context, event *nostr.Event) { return } - // if the group is closed these will be ignored group := s.GetGroupFromEvent(event) - if group.Closed { + if group == nil { return } - // otherwise anyone can join - // except for users previously removed + // Check if user was previously removed ch, err := s.DB.QueryEvents(ctx, nostr.Filter{ Kinds: []int{nostr.KindSimpleGroupRemoveUser}, Tags: nostr.TagMap{ "p": []string{event.PubKey}, + "h": []string{group.Address.ID}, }, }) if err != nil { @@ -281,7 +295,7 @@ func (s *State) ReactToJoinRequest(ctx context.Context, event *nostr.Event) { return } - // immediately add the requester + // Add the user to the group addUser := &nostr.Event{ CreatedAt: nostr.Now(), Kind: nostr.KindSimpleGroupPutUser, diff --git a/groups.go b/groups.go index 1b969b5..c36f798 100644 --- a/groups.go +++ b/groups.go @@ -65,7 +65,7 @@ func (s *State) loadGroupsFromDB(ctx context.Context) error { if err != nil { return err } - act.Apply(&group.Group) + act.Apply(&group.Group, s) } // if the group was deleted there will be no actions after the delete diff --git a/khatru29/relay_test.go b/khatru29/relay_test.go index 02a663f..cff01bc 100644 --- a/khatru29/relay_test.go +++ b/khatru29/relay_test.go @@ -536,3 +536,110 @@ func TestGroupStuffABunch(t *testing.T) { } } } + +func TestInviteCodeFunctionality(t *testing.T) { + defer startTestRelay()() + ctx := context.Background() + + user1 := "0000000000000000000000000000000000000000000000000000000000000001" + user1pk, _ := nostr.GetPublicKey(user1) + user2 := "0000000000000000000000000000000000000000000000000000000000000002" + user2pk, _ := nostr.GetPublicKey(user2) + user3 := "0000000000000000000000000000000000000000000000000000000000000003" + _, _ = nostr.GetPublicKey(user3) + + // Connect to the relay + r, err := nostr.RelayConnect(ctx, "ws://localhost:29292") + require.NoError(t, err, "failed to connect to relay") + + // Create a group + createGroup := nostr.Event{ + CreatedAt: 1, + Kind: 9007, + Tags: nostr.Tags{{"h", "invite_test"}}, + } + createGroup.Sign(user1) + require.NoError(t, r.Publish(ctx, createGroup), "failed to create group") + + // Set group to closed (kind 9002) + setGroupClosed := nostr.Event{ + CreatedAt: 2, + Kind: 9002, + Tags: nostr.Tags{ + {"h", "invite_test"}, + {"closed"}, + }, + } + setGroupClosed.Sign(user1) + require.NoError(t, r.Publish(ctx, setGroupClosed), "failed to set group as closed") + + // Create an invite code (kind 9009) + createInvite := nostr.Event{ + CreatedAt: 3, + Kind: 9009, + Tags: nostr.Tags{ + {"h", "invite_test"}, + {"code", "test_invite_123"}, + {"expiration", fmt.Sprintf("%d", nostr.Now()+3600)}, // expires in 1 hour + }, + } + createInvite.Sign(user1) + require.NoError(t, r.Publish(ctx, createInvite), "failed to create invite code") + + // Try to join with the invite code (kind 9021) + joinWithCode := nostr.Event{ + CreatedAt: 4, + Kind: 9021, + Content: "joining with invite code", + Tags: nostr.Tags{ + {"h", "invite_test"}, + {"code", "test_invite_123"}, + }, + } + joinWithCode.Sign(user2) + require.NoError(t, r.Publish(ctx, joinWithCode), "failed to join with invite code") + + // Wait for the join to be processed + time.Sleep(100 * time.Millisecond) + + // Subscribe to members to verify user2 was added + membersSub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39002}, Tags: nostr.TagMap{"d": []string{"invite_test"}}}}) + require.NoError(t, err, "failed to subscribe to group members") + + // Check if user2 is now a member + select { + case evt := <-membersSub.Events: + require.Equal(t, "invite_test", evt.Tags.GetD()) + require.NotNil(t, evt.Tags.GetFirst([]string{"p", user1pk})) + require.NotNil(t, evt.Tags.GetFirst([]string{"p", user2pk})) + require.Len(t, evt.Tags, 3) // d tag + 2 member tags + case <-time.After(time.Second): + t.Fatal("timed out waiting for member update") + } + + // Try to join with wrong invite code (should fail) + joinWithWrongCode := nostr.Event{ + CreatedAt: 5, + Kind: 9021, + Content: "trying to join with wrong code", + Tags: nostr.Tags{ + {"h", "invite_test"}, + {"code", "wrong_invite_code"}, + }, + } + joinWithWrongCode.Sign(user3) + require.Error(t, r.Publish(ctx, joinWithWrongCode), "should fail with wrong invite code") + + // Try to join again with the same valid code (should fail as already member) + joinAgain := nostr.Event{ + CreatedAt: 6, + Kind: 9021, + Content: "trying to join again", + Tags: nostr.Tags{ + {"h", "invite_test"}, + {"code", "test_invite_123"}, + }, + } + joinAgain.Sign(user2) + require.Error(t, r.Publish(ctx, joinAgain), "should fail as user is already a member") +} diff --git a/moderation_actions.go b/moderation_actions.go index 0407b79..5044261 100644 --- a/moderation_actions.go +++ b/moderation_actions.go @@ -3,6 +3,7 @@ package relay29 import ( "fmt" "slices" + "strconv" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip29" @@ -11,7 +12,7 @@ import ( var PTagNotValidPublicKey = fmt.Errorf("'p' tag value is not a valid public key") type Action interface { - Apply(group *nip29.Group) + Apply(group *nip29.Group, state *State) Name() string } @@ -21,6 +22,7 @@ var ( _ Action = CreateGroup{} _ Action = DeleteEvent{} _ Action = EditMetadata{} + _ Action = CreateInvite{} ) func PrepareModerationAction(evt *nostr.Event) (Action, error) { @@ -124,14 +126,41 @@ var moderationActionFactories = map[int]func(*nostr.Event) (Action, error){ nostr.KindSimpleGroupDeleteGroup: func(evt *nostr.Event) (Action, error) { return DeleteGroup{When: evt.CreatedAt}, nil }, + nostr.KindSimpleGroupCreateInvite: func(evt *nostr.Event) (Action, error) { + // Create invite action + codeTag := evt.Tags.GetFirst([]string{"code", ""}) + if codeTag == nil { + return nil, fmt.Errorf("missing 'code' tag") + } + + code := (*codeTag)[1] + if code == "" { + return nil, fmt.Errorf("empty invite code") + } + + var expiration *nostr.Timestamp + if expirationTag := evt.Tags.GetFirst([]string{"expiration", ""}); expirationTag != nil { + if exp, err := strconv.ParseInt((*expirationTag)[1], 10, 64); err == nil { + timestamp := nostr.Timestamp(exp) + expiration = ×tamp + } + } + + return CreateInvite{ + Code: code, + Creator: evt.PubKey, + When: evt.CreatedAt, + Expiration: expiration, + }, nil + }, } type DeleteEvent struct { Targets []string } -func (_ DeleteEvent) Name() string { return "delete-event" } -func (a DeleteEvent) Apply(group *nip29.Group) {} +func (_ DeleteEvent) Name() string { return "delete-event" } +func (a DeleteEvent) Apply(group *nip29.Group, state *State) {} type PubKeyRoles struct { PubKey string @@ -144,7 +173,7 @@ type PutUser struct { } func (_ PutUser) Name() string { return "put-user" } -func (a PutUser) Apply(group *nip29.Group) { +func (a PutUser) Apply(group *nip29.Group, state *State) { for _, target := range a.Targets { roles := make([]*nip29.Role, 0, len(target.RoleNames)) for _, roleName := range target.RoleNames { @@ -163,7 +192,7 @@ type RemoveUser struct { } func (_ RemoveUser) Name() string { return "remove-user" } -func (a RemoveUser) Apply(group *nip29.Group) { +func (a RemoveUser) Apply(group *nip29.Group, state *State) { for _, tpk := range a.Targets { delete(group.Members, tpk) } @@ -179,7 +208,7 @@ type EditMetadata struct { } func (_ EditMetadata) Name() string { return "edit-metadata" } -func (a EditMetadata) Apply(group *nip29.Group) { +func (a EditMetadata) Apply(group *nip29.Group, state *State) { group.LastMetadataUpdate = a.When if a.NameValue != nil { group.Name = *a.NameValue @@ -204,7 +233,7 @@ type CreateGroup struct { } func (_ CreateGroup) Name() string { return "create-group" } -func (a CreateGroup) Apply(group *nip29.Group) { +func (a CreateGroup) Apply(group *nip29.Group, state *State) { group.LastMetadataUpdate = a.When group.LastAdminsUpdate = a.When group.LastMembersUpdate = a.When @@ -215,7 +244,7 @@ type DeleteGroup struct { } func (_ DeleteGroup) Name() string { return "delete-group" } -func (a DeleteGroup) Apply(group *nip29.Group) { +func (a DeleteGroup) Apply(group *nip29.Group, state *State) { group.Members = make(map[string][]*nip29.Role) group.Closed = true group.Private = true @@ -226,3 +255,23 @@ func (a DeleteGroup) Apply(group *nip29.Group) { group.LastAdminsUpdate = a.When group.LastMembersUpdate = a.When } + +type CreateInvite struct { + Code string + Creator string + When nostr.Timestamp + Expiration *nostr.Timestamp +} + +func (_ CreateInvite) Name() string { return "create-invite" } +func (a CreateInvite) Apply(group *nip29.Group, state *State) { + // Store the invite code in state + inviteCode := InviteCode{ + Code: a.Code, + GroupID: group.Address.ID, + Creator: a.Creator, + CreatedAt: a.When, + Expiration: a.Expiration, + } + state.InviteCodes.Store(a.Code, inviteCode) +} diff --git a/state.go b/state.go index f09b5df..846ab2d 100644 --- a/state.go +++ b/state.go @@ -3,6 +3,7 @@ package relay29 import ( "context" "fmt" + "time" "github.com/fiatjaf/eventstore" "github.com/fiatjaf/set" @@ -11,6 +12,14 @@ import ( "github.com/puzpuzpuz/xsync/v3" ) +type InviteCode struct { + Code string + GroupID string + Creator string + CreatedAt nostr.Timestamp + Expiration *nostr.Timestamp +} + type State struct { Domain string Groups *xsync.MapOf[string, *Group] @@ -23,6 +32,9 @@ type State struct { AllowPrivateGroups bool + // Store invite codes: map[code]InviteCode + InviteCodes *xsync.MapOf[string, InviteCode] + deletedCache set.Set[string] publicKey string secretKey string @@ -50,6 +62,9 @@ func New(opts Options) *State { // we keep basic data about all groups in memory groups := xsync.NewMapOf[string, *Group]() + // we keep invite codes in memory + inviteCodes := xsync.NewMapOf[string, InviteCode]() + state := &State{ Domain: opts.Domain, Groups: groups, @@ -57,6 +72,8 @@ func New(opts Options) *State { AllowPrivateGroups: true, + InviteCodes: inviteCodes, + deletedCache: deletedCache, publicKey: pubkey, secretKey: opts.SecretKey, @@ -70,5 +87,40 @@ func New(opts Options) *State { panic(fmt.Errorf("failed to load groups from db: %w", err)) } + // start periodic cleanup of expired invite codes + go state.cleanupExpiredInviteCodes() + return state } + +// cleanupExpiredInviteCodes runs periodically to remove expired invite codes +func (s *State) cleanupExpiredInviteCodes() { + ticker := time.NewTicker(5 * time.Minute) // cleanup every 5 minutes + defer ticker.Stop() + + for range ticker.C { + now := nostr.Now() + s.InviteCodes.Range(func(code string, invite InviteCode) bool { + if invite.Expiration != nil && now > *invite.Expiration { + s.InviteCodes.Delete(code) + } + return true + }) + } +} + +// GetValidInviteCode returns the invite code if it exists and is not expired +func (s *State) GetValidInviteCode(code string) (InviteCode, bool) { + invite, exists := s.InviteCodes.Load(code) + if !exists { + return InviteCode{}, false + } + + // Check if expired + if invite.Expiration != nil && nostr.Now() > *invite.Expiration { + s.InviteCodes.Delete(code) + return InviteCode{}, false + } + + return invite, true +}