Files
sm-bm-plugin/classes/chat_context.go
Jakub Zych 8b81295458 fix: revoke chat access as soon as a user leaves the chat group
The BM chat context implements golem15.chat's LiveMembership, so every
participant check also requires chat-group membership: a removed user
loses # ogólny, their DMs, uploads, subscriptions and chat:message
delivery at once instead of after the next participant sync.
2026-10-26 19:33:52 +01:00

209 lines
7.9 KiB
Go

package classes
import (
"context"
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
chancontracts "git.golem15.com/golem15/sm-chatchannels-plugin/contracts"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
"gorm.io/gorm"
)
var (
_ chatcontracts.Context = (*ChatContext)(nil)
_ chatcontracts.MessageModerator = (*ChatContext)(nil)
_ chatcontracts.LiveMembership = (*ChatContext)(nil)
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
)
// The BM chat context: one context of type bm with id 1, whose members are
// the members of one user group.
const (
// ChatContextType is the context alias (context_type) jz.bm registers on
// golem15.chat.
ChatContextType = "bm"
// ChatContextID is the id of the one BM chat context.
ChatContextID uint = 1
// ChatGeneralName is the name of the group conversation and its channel.
ChatGeneralName = "ogólny"
// ChatGeneralSlug is the slug of the adopted channel.
ChatGeneralSlug = "ogolny"
// ChatBroadcastChannel is the host channel golem15.chat publishes created
// events to. It must never be empty: an empty host channel silences the
// conversation channels too. No client can subscribe to it, because the
// chat authorizer only admits chat:conversation:{id}.
ChatBroadcastChannel = "chat:context:1"
)
// MemberFunc reports whether userID belongs to the user group with code
// group. IsChatMember is the production check; tests inject their own.
type MemberFunc func(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error)
// IsChatMember reports whether userID is in the chat group group. No user,
// no group code or no database is never a member.
func IsChatMember(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error) {
if userID == 0 || group == "" || db == nil {
return false, nil
}
return userclasses.HasGroupCode(ctx, db, userID, group)
}
// IsChatModerator reports whether userID is in the moderator group
// moderatorGroup.
func IsChatModerator(ctx context.Context, db *gorm.DB, userID uint, moderatorGroup string) (bool, error) {
return IsChatMember(ctx, db, userID, moderatorGroup)
}
// ChatMemberIDs lists the ids of the users in group that are not deleted,
// ordered by id. A group code with no group row has no member.
func ChatMemberIDs(ctx context.Context, db *gorm.DB, group string) ([]uint, error) {
ids := []uint{}
if group == "" || db == nil {
return ids, nil
}
err := db.WithContext(ctx).
Table("users_groups").
Joins("JOIN user_groups ON user_groups.id = users_groups.user_group_id").
Joins("JOIN users ON users.id = users_groups.user_id").
Where("user_groups.code = ? AND users.deleted_at IS NULL", group).
Distinct().
Order("users_groups.user_id").
Pluck("users_groups.user_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
// ChatContext is the golem15.chat context of the BM chat. Membership is the
// user group named by group; nobody oversees other people's chats, so a
// moderator reads no DM and edits no foreign message. Moderators delete
// through CanModerateMessage, in group conversations only.
type ChatContext struct {
db *gorm.DB
group string
moderatorGroup string
isMember MemberFunc
}
// NewChatContext returns the BM chat context over db for the chat group
// code group and the moderator group code moderatorGroup.
func NewChatContext(db *gorm.DB, group, moderatorGroup string) *ChatContext {
return &ChatContext{db: db, group: group, moderatorGroup: moderatorGroup}
}
// WithMembership replaces the group check (IsChatMember) and returns c.
func (c *ChatContext) WithMembership(fn MemberFunc) *ChatContext {
c.isMember = fn
return c
}
func (c *ChatContext) ID() uint { return ChatContextID }
func (c *ChatContext) Type() string { return ChatContextType }
func (c *ChatContext) ChatEnabled() bool { return true }
func (c *ChatContext) GroupName() string { return ChatGeneralName }
func (c *ChatContext) BroadcastChannel() string { return ChatBroadcastChannel }
// CanActFor is the default: a user acts only for themself.
func (c *ChatContext) CanActFor(ctx context.Context, actorID, memberID uint) bool {
return chatcontracts.DefaultCanActFor(ctx, actorID, memberID)
}
// CanOverseeChats is always false. golem15.chat grants an overseer read
// access to every conversation and edits of other people's messages, which
// no BM user gets; moderators delete through a narrower hook.
func (c *ChatContext) CanOverseeChats(context.Context, uint) (bool, error) {
return false, nil
}
// CanModerateMessage is golem15.chat's delete-only moderation hook (D-10):
// a member of the moderator group who is also a chat-group member may
// delete other people's messages in group conversations (# ogólny). In a
// DM the answer is always false, so moderators never act on private
// conversations (D-24). It grants no viewing or editing.
func (c *ChatContext) CanModerateMessage(ctx context.Context, userID uint, conversationType string) (bool, error) {
if conversationType != "group" || userID == 0 || c.moderatorGroup == "" {
return false, nil
}
member, err := c.HasMember(ctx, userID)
if err != nil || !member {
return false, err
}
if c.isMember != nil {
return c.isMember(ctx, c.db, userID, c.moderatorGroup)
}
return IsChatModerator(ctx, c.db, userID, c.moderatorGroup)
}
// RequireLiveMembership is always true: golem15.chat checks HasMember on
// every participant check of the BM chat, so someone taken out of the
// chat group loses # ogólny and their DMs at once, not when their
// participant rows are pruned.
func (c *ChatContext) RequireLiveMembership() bool { return true }
// HasMember reports whether userID is in the chat group.
func (c *ChatContext) HasMember(ctx context.Context, userID uint) (bool, error) {
if userID == 0 || c.group == "" {
return false, nil
}
if c.isMember != nil {
return c.isMember(ctx, c.db, userID, c.group)
}
return IsChatMember(ctx, c.db, userID, c.group)
}
// Members lists the ids of the chat group's users that are not deleted.
func (c *ChatContext) Members(ctx context.Context) ([]uint, error) {
return ChatMemberIDs(ctx, c.db, c.group)
}
// ChatContextLoader is the golem15.chat loader of the bm alias. The only
// context is id 1; any other id is unknown (nil, nil), which golem15.chat
// answers with its not-a-member error. The group codes are read per load,
// so a config change needs no re-registration.
func ChatContextLoader(group, moderatorGroup func() string) chatcontracts.Loader {
return func(_ context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) {
if id != ChatContextID {
return nil, nil
}
return NewChatContext(db, group(), moderatorGroup()), nil
}
}
// ChatAccess is golem15.chatchannels' access policy for BM: chat-group
// members may access context bm/1 and nothing else, and nobody manages it,
// so no channel is ever created through the channel service.
type ChatAccess struct {
db func() *gorm.DB
group string
isMember MemberFunc
}
// NewChatAccess returns the access policy for the chat group group. db is
// resolved per check; isMember nil means IsChatMember.
func NewChatAccess(db func() *gorm.DB, group string, isMember MemberFunc) *ChatAccess {
if isMember == nil {
isMember = IsChatMember
}
return &ChatAccess{db: db, group: group, isMember: isMember}
}
// CanAccessContext is true only for a chat-group member and context bm/1.
// Any error, or no database, denies.
func (a *ChatAccess) CanAccessContext(ctx context.Context, userID uint, contextType string, contextID uint) bool {
if contextType != ChatContextType || contextID != ChatContextID || userID == 0 || a.group == "" || a.db == nil {
return false
}
db := a.db()
if db == nil {
return false
}
ok, err := a.isMember(ctx, db, userID, a.group)
return err == nil && ok
}
// CanManageContext is always false: nobody creates or edits channels.
func (a *ChatAccess) CanManageContext(context.Context, uint, string, uint) bool {
return false
}