Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions event_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions khatru29/relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
65 changes: 57 additions & 8 deletions moderation_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package relay29
import (
"fmt"
"slices"
"strconv"

"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip29"
Expand All @@ -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
}

Expand All @@ -21,6 +22,7 @@ var (
_ Action = CreateGroup{}
_ Action = DeleteEvent{}
_ Action = EditMetadata{}
_ Action = CreateInvite{}
)

func PrepareModerationAction(evt *nostr.Event) (Action, error) {
Expand Down Expand Up @@ -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 = &timestamp
}
}

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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Loading