diff --git a/Server/service/moderation.go b/Server/service/moderation.go new file mode 100644 index 00000000..232b2a98 --- /dev/null +++ b/Server/service/moderation.go @@ -0,0 +1,69 @@ +package service + +import ( + "fmt" + "log/slog" + "time" + + "github.com/owncord/server/store" +) + +// ModerationService handles user ban/unban operations. +type ModerationService struct { + st store.Store +} + +// NewModerationService creates a ModerationService. +func NewModerationService(st store.Store) *ModerationService { + return &ModerationService{st: st} +} + +// BanUser bans a target user. Validates the target exists and +// prevents self-banning. +func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expires *time.Time) error { + if targetID <= 0 { + return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) + } + if actorID == targetID { + return fmt.Errorf("%w: cannot ban yourself", ErrBadRequest) + } + + target, err := s.st.GetUserByID(targetID) + if err != nil || target == nil { + return fmt.Errorf("%w: user not found", ErrNotFound) + } + + if err := s.st.BanUser(targetID, reason, expires); err != nil { + return fmt.Errorf("%w: failed to ban user", ErrInternal) + } + + if err := s.st.LogAudit(actorID, "ban", "user", targetID, reason); err != nil { + slog.Error("failed to log audit entry", "error", err) + } + + slog.Info("user banned", "actor_id", actorID, "target_id", targetID, "reason", reason) + return nil +} + +// UnbanUser removes a ban on a target user. +func (s *ModerationService) UnbanUser(actorID, targetID int64) error { + if targetID <= 0 { + return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) + } + + target, err := s.st.GetUserByID(targetID) + if err != nil || target == nil { + return fmt.Errorf("%w: user not found", ErrNotFound) + } + + if err := s.st.UnbanUser(targetID); err != nil { + return fmt.Errorf("%w: failed to unban user", ErrInternal) + } + + if err := s.st.LogAudit(actorID, "unban", "user", targetID, ""); err != nil { + slog.Error("failed to log audit entry", "error", err) + } + + slog.Info("user unbanned", "actor_id", actorID, "target_id", targetID) + return nil +} diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go new file mode 100644 index 00000000..b1e58c4c --- /dev/null +++ b/Server/service/permission_test.go @@ -0,0 +1,192 @@ +package service + +import ( + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/store" +) + +// newTestPermService creates a PermissionService backed by a MemStore +// pre-populated with a single role and user. +func newTestPermService() (*PermissionService, *store.MemStore) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions, + Position: 1, + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + checker := permissions.NewChecker(ms) + return NewPermissionService(ms, checker), ms +} + +func TestHasChannelPerm_Allowed(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply. + if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected user to have SendMessages permission") + } + if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + t.Fatal("expected user to have ReadMessages permission") + } +} + +func TestHasChannelPerm_Denied(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // ManageMessages is NOT in the member role. + if svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + t.Fatal("expected user to NOT have ManageMessages permission") + } +} + +func TestHasChannelPerm_OverrideDeny(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "readonly", Type: "text"}) + // Deny SendMessages for this channel. + ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + // Invalidate so next check re-populates cache. + svc.InvalidateAll() + + if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected SendMessages to be denied via channel override") + } + // ReadMessages should still be allowed. + if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + t.Fatal("expected ReadMessages to remain allowed") + } +} + +func TestHasChannelPerm_OverrideAllow(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "special", Type: "text"}) + // Allow ManageMessages (not in base role) via channel override. + ms.SeedChannelOverride(permissions.MemberRoleID, 10, permissions.ManageMessages, 0) + svc.InvalidateAll() + + if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + t.Fatal("expected ManageMessages to be allowed via channel override") + } +} + +func TestHasChannelPerm_AdminBypass(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.AdminRoleID, + Name: "admin", + Permissions: permissions.Administrator, + Position: 90, + }) + ms.SeedUserRole(1, permissions.AdminRoleID) + checker := permissions.NewChecker(ms) + svc := NewPermissionService(ms, checker) + + ms.SeedChannel(&db.Channel{ID: 10, Name: "locked", Type: "text"}) + // Deny everything via override; admin should still bypass. + ms.SeedChannelOverride(permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages) + + if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("admin should bypass all permission checks") + } + if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + t.Fatal("admin should bypass all permission checks") + } +} + +func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // Populate cache. + svc.HasChannelPerm(1, 10, permissions.SendMessages) + + // Now add a deny override. + ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + + // Without invalidation, cache still says allowed. + if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected cached value to still allow SendMessages") + } + + // After invalidation, should pick up the override. + svc.InvalidateUser(1) + if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected SendMessages to be denied after cache invalidation") + } +} + +func TestInvalidateAll_ClearsEntireCache(t *testing.T) { + svc, ms := newTestPermService() + // Add a second user. + ms.SeedUserRole(2, permissions.MemberRoleID) + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // Populate cache for both users. + svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(2, 10, permissions.SendMessages) + + // Add deny override. + ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + + // Both still cached as allowed. + if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected cached allow for user 1") + } + if !svc.HasChannelPerm(2, 10, permissions.SendMessages) { + t.Fatal("expected cached allow for user 2") + } + + svc.InvalidateAll() + + // Both should now see the deny. + if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected deny for user 1 after InvalidateAll") + } + if svc.HasChannelPerm(2, 10, permissions.SendMessages) { + t.Fatal("expected deny for user 2 after InvalidateAll") + } +} + +func TestPermCacheTTLExpiry(t *testing.T) { + // This test verifies the cache TTL mechanism. We cannot easily wait 30s + // in a unit test, so we verify the structural behavior: after manually + // backdating the populatedAt field the cache should be stale and the + // next check should re-populate from the store. + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // Populate cache. + svc.HasChannelPerm(1, 10, permissions.SendMessages) + + // Add deny override. + ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + + // Manually expire the cache entry by backdating populatedAt. + svc.mu.Lock() + if cp, ok := svc.cache[int64(1)]; ok { + cp.populatedAt = time.Now().Add(-permCacheTTL - time.Second) + } + svc.mu.Unlock() + + // The next call should re-populate and pick up the deny. + if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("expected cache TTL expiry to cause re-population with deny override") + } +} + +func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) { + svc, ms := newTestPermService() + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + + // User 999 has no role assigned. + if svc.HasChannelPerm(999, 10, permissions.SendMessages) { + t.Fatal("expected false for unknown user") + } +} diff --git a/Server/service/service.go b/Server/service/service.go index 6358bcc4..c8d7ecee 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -20,6 +20,8 @@ type Services struct { DMs *DMService Invites *InviteService Blocks *BlockService + Moderation *ModerationService + Voice *VoiceService } // New creates all domain services wired together. @@ -34,5 +36,7 @@ func New(st store.Store, limiter *auth.RateLimiter) *Services { DMs: NewDMService(st), Invites: NewInviteService(st), Blocks: NewBlockService(st), + Moderation: NewModerationService(st), + Voice: NewVoiceService(st, permSvc), } } diff --git a/Server/service/voice.go b/Server/service/voice.go new file mode 100644 index 00000000..6a2ba2e8 --- /dev/null +++ b/Server/service/voice.go @@ -0,0 +1,137 @@ +package service + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/store" +) + +// VoiceService handles voice state business logic. +type VoiceService struct { + st store.Store + perm *PermissionService +} + +// NewVoiceService creates a VoiceService. +func NewVoiceService(st store.Store, perm *PermissionService) *VoiceService { + return &VoiceService{st: st, perm: perm} +} + +// JoinChannel validates the channel, checks ConnectVoice permission, and +// joins the user to the voice channel respecting capacity limits. +// Returns the channel on success so callers can access voice config fields. +func (s *VoiceService) JoinChannel(userID, channelID int64) (*db.Channel, error) { + if channelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) + } + + ch, err := s.st.GetChannel(channelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + + if !s.perm.HasChannelPerm(userID, channelID, permissions.ConnectVoice) { + return nil, fmt.Errorf("%w: missing CONNECT_VOICE permission", ErrForbidden) + } + + maxUsers := ch.VoiceMaxUsers + if maxUsers > 0 { + if err := s.st.JoinVoiceChannelIfCapacity(userID, channelID, maxUsers); err != nil { + if errors.Is(err, db.ErrChannelFull) { + return nil, fmt.Errorf("%w: voice channel is full", ErrForbidden) + } + slog.Error("VoiceService.JoinChannel JoinVoiceChannelIfCapacity", "err", err, "user_id", userID) + return nil, fmt.Errorf("%w: failed to join voice channel", ErrInternal) + } + } else { + if err := s.st.JoinVoiceChannel(userID, channelID); err != nil { + slog.Error("VoiceService.JoinChannel JoinVoiceChannel", "err", err, "user_id", userID) + return nil, fmt.Errorf("%w: failed to join voice channel", ErrInternal) + } + } + + slog.Info("voice join", "user_id", userID, "channel_id", channelID) + return ch, nil +} + +// LeaveChannel removes the user from their current voice channel. +func (s *VoiceService) LeaveChannel(userID int64) error { + if err := s.st.LeaveVoiceChannel(userID); err != nil { + slog.Error("VoiceService.LeaveChannel", "err", err, "user_id", userID) + return fmt.Errorf("%w: failed to leave voice channel", ErrInternal) + } + + slog.Info("voice leave", "user_id", userID) + return nil +} + +// UpdateMute toggles the mute state for the given user. +func (s *VoiceService) UpdateMute(userID int64, muted bool) error { + if err := s.st.UpdateVoiceMute(userID, muted); err != nil { + slog.Error("VoiceService.UpdateMute", "err", err, "user_id", userID) + return fmt.Errorf("%w: failed to update mute state", ErrInternal) + } + + slog.Debug("voice mute changed", "user_id", userID, "muted", muted) + return nil +} + +// UpdateDeafen toggles the deafen state for the given user. +func (s *VoiceService) UpdateDeafen(userID int64, deafened bool) error { + if err := s.st.UpdateVoiceDeafen(userID, deafened); err != nil { + slog.Error("VoiceService.UpdateDeafen", "err", err, "user_id", userID) + return fmt.Errorf("%w: failed to update deafen state", ErrInternal) + } + + slog.Debug("voice deafen changed", "user_id", userID, "deafened", deafened) + return nil +} + +// ToggleCamera enables or disables the user's camera. When enabling, it +// enforces the maxVideo limit via an atomic check-and-update. Returns true +// if the camera was successfully enabled (or disabled), false if the video +// limit was reached. +func (s *VoiceService) ToggleCamera(userID, channelID int64, enable bool, maxVideo int) (bool, error) { + if !s.perm.HasChannelPerm(userID, channelID, permissions.UseVideo) { + return false, fmt.Errorf("%w: missing USE_VIDEO permission", ErrForbidden) + } + + if enable && maxVideo > 0 { + ok, err := s.st.EnableCameraIfUnderLimit(userID, channelID, maxVideo) + if err != nil { + slog.Error("VoiceService.ToggleCamera EnableCameraIfUnderLimit", "err", err, "user_id", userID) + return false, fmt.Errorf("%w: failed to check video limit", ErrInternal) + } + if !ok { + return false, nil + } + } else { + if err := s.st.UpdateVoiceCamera(userID, enable); err != nil { + slog.Error("VoiceService.ToggleCamera UpdateVoiceCamera", "err", err, "user_id", userID) + return false, fmt.Errorf("%w: failed to update camera state", ErrInternal) + } + } + + slog.Debug("voice camera changed", "user_id", userID, "enabled", enable, "channel_id", channelID) + return true, nil +} + +// ToggleScreenshare enables or disables the user's screen share after +// checking the ShareScreen permission. +func (s *VoiceService) ToggleScreenshare(userID, channelID int64, enable bool) error { + if !s.perm.HasChannelPerm(userID, channelID, permissions.ShareScreen) { + return fmt.Errorf("%w: missing SHARE_SCREEN permission", ErrForbidden) + } + + if err := s.st.UpdateVoiceScreenshare(userID, enable); err != nil { + slog.Error("VoiceService.ToggleScreenshare", "err", err, "user_id", userID) + return fmt.Errorf("%w: failed to update screenshare state", ErrInternal) + } + + slog.Debug("voice screenshare changed", "user_id", userID, "enabled", enable, "channel_id", channelID) + return nil +} diff --git a/Server/store/memstore.go b/Server/store/memstore.go new file mode 100644 index 00000000..109c0940 --- /dev/null +++ b/Server/store/memstore.go @@ -0,0 +1,760 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + "github.com/owncord/server/db" +) + +// compile-time interface check +var _ Store = (*MemStore)(nil) + +// MemStore is a lightweight in-memory Store implementation for testing. +// Only the methods needed by service-layer tests are implemented; +// everything else panics with a descriptive message. +type MemStore struct { + mu sync.Mutex + + // auto-increment counters + nextMsgID int64 + nextChannelID int64 + + channels map[int64]*db.Channel + messages map[int64]*db.Message + users map[int64]*db.User + roles map[int64]*db.Role + // userID -> roleID + userRoles map[int64]int64 + // roleID -> channelID -> override + channelOverrides map[int64]map[int64]db.ChannelOverride + // messageID -> userID -> emoji -> bool + reactions map[int64]map[int64]map[string]bool + // channelID -> set of participant userIDs + dmParticipants map[int64]map[int64]bool + // blockerID -> blockedID -> bool (bidirectional checked via IsEitherBlocked) + blocks map[int64]map[int64]bool + // userID -> channelID -> lastReadMessageID + readStates map[int64]map[int64]int64 +} + +// NewMemStore creates an empty MemStore ready for use. +func NewMemStore() *MemStore { + return &MemStore{ + channels: make(map[int64]*db.Channel), + messages: make(map[int64]*db.Message), + users: make(map[int64]*db.User), + roles: make(map[int64]*db.Role), + userRoles: make(map[int64]int64), + channelOverrides: make(map[int64]map[int64]db.ChannelOverride), + reactions: make(map[int64]map[int64]map[string]bool), + dmParticipants: make(map[int64]map[int64]bool), + blocks: make(map[int64]map[int64]bool), + readStates: make(map[int64]map[int64]int64), + } +} + +// ---------- helpers for test setup ---------- + +// SeedChannel inserts a channel directly (for test setup). +func (m *MemStore) SeedChannel(ch *db.Channel) { + m.mu.Lock() + defer m.mu.Unlock() + m.channels[ch.ID] = ch + if ch.ID >= m.nextChannelID { + m.nextChannelID = ch.ID + 1 + } +} + +// SeedUser inserts a user directly (for test setup). +func (m *MemStore) SeedUser(u *db.User) { + m.mu.Lock() + defer m.mu.Unlock() + m.users[u.ID] = u +} + +// SeedRole inserts a role directly (for test setup). +func (m *MemStore) SeedRole(r *db.Role) { + m.mu.Lock() + defer m.mu.Unlock() + m.roles[r.ID] = r +} + +// SeedUserRole assigns a role to a user (for test setup). +func (m *MemStore) SeedUserRole(userID, roleID int64) { + m.mu.Lock() + defer m.mu.Unlock() + m.userRoles[userID] = roleID +} + +// SeedChannelOverride sets a channel permission override for a role (for test setup). +func (m *MemStore) SeedChannelOverride(roleID, channelID int64, allow, deny int64) { + m.mu.Lock() + defer m.mu.Unlock() + if m.channelOverrides[roleID] == nil { + m.channelOverrides[roleID] = make(map[int64]db.ChannelOverride) + } + m.channelOverrides[roleID][channelID] = db.ChannelOverride{Allow: allow, Deny: deny} +} + +// SeedDMParticipant adds a user as a DM participant in a channel (for test setup). +func (m *MemStore) SeedDMParticipant(channelID, userID int64) { + m.mu.Lock() + defer m.mu.Unlock() + if m.dmParticipants[channelID] == nil { + m.dmParticipants[channelID] = make(map[int64]bool) + } + m.dmParticipants[channelID][userID] = true +} + +// SeedBlock records a block relationship (for test setup). +func (m *MemStore) SeedBlock(blockerID, blockedID int64) { + m.mu.Lock() + defer m.mu.Unlock() + if m.blocks[blockerID] == nil { + m.blocks[blockerID] = make(map[int64]bool) + } + m.blocks[blockerID][blockedID] = true +} + +// ---------- Store interface: top-level ---------- + +func (m *MemStore) Close() error { return nil } +func (m *MemStore) SQLDb() *sql.DB { return nil } +func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) } + +// ---------- MessageStore ---------- + +func (m *MemStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextMsgID++ + id := m.nextMsgID + m.messages[id] = &db.Message{ + ID: id, + ChannelID: channelID, + UserID: userID, + Content: content, + ReplyTo: replyTo, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + return id, nil +} + +func (m *MemStore) GetMessage(id int64) (*db.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + msg, ok := m.messages[id] + if !ok { + return nil, fmt.Errorf("message %d not found", id) + } + // Return a copy to avoid aliasing. + cp := *msg + return &cp, nil +} + +func (m *MemStore) GetMessages(_ int64, _ int64, _ int) ([]db.MessageWithUser, error) { + panic("memstore: not implemented: GetMessages") +} + +func (m *MemStore) GetMessagesForAPI(_ int64, _ int64, _ int, _ int64) ([]db.MessageAPIResponse, error) { + return []db.MessageAPIResponse{}, nil +} + +func (m *MemStore) EditMessage(id, userID int64, content string) error { + m.mu.Lock() + defer m.mu.Unlock() + msg, ok := m.messages[id] + if !ok { + return fmt.Errorf("message %d not found", id) + } + if msg.UserID != userID { + return fmt.Errorf("not message owner") + } + now := time.Now().UTC().Format(time.RFC3339) + msg.Content = content + msg.EditedAt = &now + return nil +} + +func (m *MemStore) DeleteMessage(id, userID int64, isMod bool) error { + m.mu.Lock() + defer m.mu.Unlock() + msg, ok := m.messages[id] + if !ok { + return fmt.Errorf("message %d not found", id) + } + if !isMod && msg.UserID != userID { + return fmt.Errorf("not message owner") + } + msg.Deleted = true + return nil +} + +func (m *MemStore) SearchMessages(_ string, _ *int64, _ int) ([]db.MessageSearchResult, error) { + return []db.MessageSearchResult{}, nil +} + +func (m *MemStore) SearchMessagesInChannels(_ string, _ []int64, _ int) ([]db.MessageSearchResult, error) { + return []db.MessageSearchResult{}, nil +} + +func (m *MemStore) GetPinnedMessages(_ int64, _ int64) ([]db.MessageAPIResponse, error) { + return []db.MessageAPIResponse{}, nil +} + +func (m *MemStore) SetMessagePinned(_ int64, _ bool) error { return nil } + +func (m *MemStore) AddReaction(messageID, userID int64, emoji string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.messages[messageID]; !ok { + return fmt.Errorf("message %d not found", messageID) + } + if m.reactions[messageID] == nil { + m.reactions[messageID] = make(map[int64]map[string]bool) + } + if m.reactions[messageID][userID] == nil { + m.reactions[messageID][userID] = make(map[string]bool) + } + if m.reactions[messageID][userID][emoji] { + return fmt.Errorf("reaction already exists") + } + m.reactions[messageID][userID][emoji] = true + return nil +} + +func (m *MemStore) RemoveReaction(messageID, userID int64, emoji string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.reactions[messageID] == nil || m.reactions[messageID][userID] == nil || !m.reactions[messageID][userID][emoji] { + return fmt.Errorf("reaction not found") + } + delete(m.reactions[messageID][userID], emoji) + return nil +} + +func (m *MemStore) GetReactions(_ int64) ([]db.ReactionCount, error) { + return []db.ReactionCount{}, nil +} + +func (m *MemStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.readStates[userID] == nil { + m.readStates[userID] = make(map[int64]int64) + } + m.readStates[userID][channelID] = lastReadMessageID + return nil +} + +func (m *MemStore) GetChannelUnreadCounts(_ int64) (map[int64]db.ChannelUnread, error) { + return map[int64]db.ChannelUnread{}, nil +} + +func (m *MemStore) GetLatestMessageID(channelID int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var latest int64 + for _, msg := range m.messages { + if msg.ChannelID == channelID && msg.ID > latest { + latest = msg.ID + } + } + return latest, nil +} + +func (m *MemStore) LinkAttachmentsToMessage(_ int64, _ []string) (int64, error) { + return 0, nil +} + +func (m *MemStore) GetAttachmentsByMessageIDs(_ []int64) (map[int64][]db.AttachmentInfo, error) { + return map[int64][]db.AttachmentInfo{}, nil +} + +// ---------- ChannelStore ---------- + +func (m *MemStore) ListChannels() ([]db.Channel, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]db.Channel, 0, len(m.channels)) + for _, ch := range m.channels { + out = append(out, *ch) + } + return out, nil +} + +func (m *MemStore) GetChannel(id int64) (*db.Channel, error) { + m.mu.Lock() + defer m.mu.Unlock() + ch, ok := m.channels[id] + if !ok { + return nil, fmt.Errorf("channel %d not found", id) + } + cp := *ch + return &cp, nil +} + +func (m *MemStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextChannelID++ + id := m.nextChannelID + m.channels[id] = &db.Channel{ + ID: id, + Name: name, + Type: chanType, + Category: category, + Topic: topic, + Position: position, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + return id, nil +} + +func (m *MemStore) UpdateChannel(_ int64, _, _ string, _ int) error { + panic("memstore: not implemented: UpdateChannel") +} + +func (m *MemStore) DeleteChannel(_ int64) error { + panic("memstore: not implemented: DeleteChannel") +} + +func (m *MemStore) SetChannelSlowMode(_ int64, _ int) error { + panic("memstore: not implemented: SetChannelSlowMode") +} + +func (m *MemStore) SetChannelVoiceMaxUsers(_ int64, _ int) error { + panic("memstore: not implemented: SetChannelVoiceMaxUsers") +} + +func (m *MemStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + overrides, ok := m.channelOverrides[roleID] + if !ok { + return 0, 0, nil + } + o, ok := overrides[channelID] + if !ok { + return 0, 0, nil + } + return o.Allow, o.Deny, nil +} + +func (m *MemStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { + m.mu.Lock() + defer m.mu.Unlock() + overrides, ok := m.channelOverrides[roleID] + if !ok { + return map[int64]db.ChannelOverride{}, nil + } + // Copy map. + out := make(map[int64]db.ChannelOverride, len(overrides)) + for k, v := range overrides { + out[k] = v + } + return out, nil +} + +func (m *MemStore) GetChannelTypes(_ []int64) (map[int64]string, error) { + panic("memstore: not implemented: GetChannelTypes") +} + +// ---------- UserStore ---------- + +func (m *MemStore) GetUserByID(id int64) (*db.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.users[id] + if !ok { + return nil, fmt.Errorf("user %d not found", id) + } + cp := *u + return &cp, nil +} + +func (m *MemStore) GetUserByUsername(_ string) (*db.User, error) { + panic("memstore: not implemented: GetUserByUsername") +} + +func (m *MemStore) CreateUser(_, _ string, _ int) (int64, error) { + panic("memstore: not implemented: CreateUser") +} + +func (m *MemStore) CreateOwnerIfEmpty(_, _ string, _ int) (int64, error) { + panic("memstore: not implemented: CreateOwnerIfEmpty") +} + +func (m *MemStore) CreateUserWithInvite(_, _ string, _ int, _ string) (int64, error) { + panic("memstore: not implemented: CreateUserWithInvite") +} + +func (m *MemStore) UpdateUserProfile(_ int64, _ string, _ *string) error { + panic("memstore: not implemented: UpdateUserProfile") +} + +func (m *MemStore) UpdateUserPassword(_ int64, _ string) error { + panic("memstore: not implemented: UpdateUserPassword") +} + +func (m *MemStore) UpdateUserStatus(id int64, status string) error { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.users[id] + if !ok { + return fmt.Errorf("user %d not found", id) + } + u.Status = status + return nil +} + +func (m *MemStore) UpdateUserTOTPSecret(_ int64, _ *string) error { + panic("memstore: not implemented: UpdateUserTOTPSecret") +} + +func (m *MemStore) UpdateUserRole(_ int64, _ int64) error { + panic("memstore: not implemented: UpdateUserRole") +} + +func (m *MemStore) ResetAllUserStatuses() error { + panic("memstore: not implemented: ResetAllUserStatuses") +} + +func (m *MemStore) DeleteAccount(_ context.Context, _ int64) error { + panic("memstore: not implemented: DeleteAccount") +} + +func (m *MemStore) ListMembers() ([]db.MemberSummary, error) { + panic("memstore: not implemented: ListMembers") +} + +// ---------- SessionStore ---------- + +func (m *MemStore) CreateSession(_ int64, _, _, _ string) (int64, error) { + panic("memstore: not implemented: CreateSession") +} + +func (m *MemStore) GetSessionByTokenHash(_ string) (*db.Session, error) { + panic("memstore: not implemented: GetSessionByTokenHash") +} + +func (m *MemStore) GetSessionWithBanStatus(_ string) (*db.SessionWithBanStatus, error) { + panic("memstore: not implemented: GetSessionWithBanStatus") +} + +func (m *MemStore) DeleteSession(_ string) error { + panic("memstore: not implemented: DeleteSession") +} + +func (m *MemStore) DeleteOtherSessions(_ int64, _ int64) (int64, error) { + panic("memstore: not implemented: DeleteOtherSessions") +} + +func (m *MemStore) DeleteExpiredSessions() error { + panic("memstore: not implemented: DeleteExpiredSessions") +} + +func (m *MemStore) DeleteSessionByID(_ int64, _ int64) error { + panic("memstore: not implemented: DeleteSessionByID") +} + +func (m *MemStore) TouchSession(_ string) error { + panic("memstore: not implemented: TouchSession") +} + +func (m *MemStore) ListUserSessions(_ int64) ([]db.Session, error) { + panic("memstore: not implemented: ListUserSessions") +} + +func (m *MemStore) ForceLogoutUser(_ int64) error { + panic("memstore: not implemented: ForceLogoutUser") +} + +func (m *MemStore) GetUserSessions(_ int64) ([]db.Session, error) { + panic("memstore: not implemented: GetUserSessions") +} + +// ---------- RoleStore ---------- + +func (m *MemStore) GetRoleByID(id int64) (*db.Role, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.roles[id] + if !ok { + return nil, fmt.Errorf("role %d not found", id) + } + cp := *r + return &cp, nil +} + +func (m *MemStore) GetRoleForUser(userID int64) (*db.Role, error) { + m.mu.Lock() + defer m.mu.Unlock() + roleID, ok := m.userRoles[userID] + if !ok { + return nil, fmt.Errorf("no role for user %d", userID) + } + r, ok := m.roles[roleID] + if !ok { + return nil, fmt.Errorf("role %d not found", roleID) + } + cp := *r + return &cp, nil +} + +func (m *MemStore) GetUserWithRole(_ int64) (*db.User, *db.Role, error) { + panic("memstore: not implemented: GetUserWithRole") +} + +func (m *MemStore) ListRoles() ([]*db.Role, error) { + panic("memstore: not implemented: ListRoles") +} + +// ---------- InviteStore ---------- + +func (m *MemStore) CreateInvite(_ int64, _ int, _ *time.Time) (string, error) { + panic("memstore: not implemented: CreateInvite") +} + +func (m *MemStore) GetInvite(_ string) (*db.Invite, error) { + panic("memstore: not implemented: GetInvite") +} + +func (m *MemStore) ListInvites() ([]*db.Invite, error) { + panic("memstore: not implemented: ListInvites") +} + +func (m *MemStore) UseInviteAtomic(_ string) error { + panic("memstore: not implemented: UseInviteAtomic") +} + +func (m *MemStore) RevokeInvite(_ string) error { + panic("memstore: not implemented: RevokeInvite") +} + +// ---------- VoiceStore ---------- + +func (m *MemStore) JoinVoiceChannel(_ int64, _ int64) error { + panic("memstore: not implemented: JoinVoiceChannel") +} + +func (m *MemStore) JoinVoiceChannelIfCapacity(_ int64, _ int64, _ int) error { + panic("memstore: not implemented: JoinVoiceChannelIfCapacity") +} + +func (m *MemStore) LeaveVoiceChannel(_ int64) error { + panic("memstore: not implemented: LeaveVoiceChannel") +} + +func (m *MemStore) LeaveVoiceChannelIfMatch(_ int64, _ int64, _ string) (bool, error) { + panic("memstore: not implemented: LeaveVoiceChannelIfMatch") +} + +func (m *MemStore) GetVoiceState(_ int64) (*db.VoiceState, error) { + panic("memstore: not implemented: GetVoiceState") +} + +func (m *MemStore) GetChannelVoiceStates(_ int64) ([]db.VoiceState, error) { + panic("memstore: not implemented: GetChannelVoiceStates") +} + +func (m *MemStore) GetAllVoiceStates() ([]db.VoiceState, error) { + panic("memstore: not implemented: GetAllVoiceStates") +} + +func (m *MemStore) UpdateVoiceMute(_ int64, _ bool) error { + panic("memstore: not implemented: UpdateVoiceMute") +} + +func (m *MemStore) UpdateVoiceDeafen(_ int64, _ bool) error { + panic("memstore: not implemented: UpdateVoiceDeafen") +} + +func (m *MemStore) ClearVoiceState(_ int64) error { + panic("memstore: not implemented: ClearVoiceState") +} + +func (m *MemStore) ClearAllVoiceStates() error { + panic("memstore: not implemented: ClearAllVoiceStates") +} + +func (m *MemStore) CountActiveCameras(_ int64) (int, error) { + panic("memstore: not implemented: CountActiveCameras") +} + +func (m *MemStore) UpdateVoiceCamera(_ int64, _ bool) error { + panic("memstore: not implemented: UpdateVoiceCamera") +} + +func (m *MemStore) EnableCameraIfUnderLimit(_ int64, _ int64, _ int) (bool, error) { + panic("memstore: not implemented: EnableCameraIfUnderLimit") +} + +func (m *MemStore) UpdateVoiceScreenshare(_ int64, _ bool) error { + panic("memstore: not implemented: UpdateVoiceScreenshare") +} + +func (m *MemStore) CountChannelVoiceUsers(_ int64) (int, error) { + panic("memstore: not implemented: CountChannelVoiceUsers") +} + +// ---------- DMStore ---------- + +func (m *MemStore) GetOrCreateDMChannel(_ int64, _ int64) (*db.Channel, bool, error) { + panic("memstore: not implemented: GetOrCreateDMChannel") +} + +func (m *MemStore) GetUserDMChannels(_ int64) ([]db.DMChannelInfo, error) { + return []db.DMChannelInfo{}, nil +} + +func (m *MemStore) OpenDM(_, _ int64) error { return nil } +func (m *MemStore) CloseDM(_, _ int64) error { return nil } + +func (m *MemStore) IsDMParticipant(userID, channelID int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + participants, ok := m.dmParticipants[channelID] + if !ok { + return false, nil + } + return participants[userID], nil +} + +func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + participants, ok := m.dmParticipants[channelID] + if !ok { + return []int64{}, nil + } + ids := make([]int64, 0, len(participants)) + for id := range participants { + ids = append(ids, id) + } + return ids, nil +} + +func (m *MemStore) GetDMRecipient(_ int64, _ int64) (*db.User, error) { + return nil, nil +} + +// ---------- BlockStore ---------- + +func (m *MemStore) BlockUser(_, _ int64) error { return nil } +func (m *MemStore) UnblockUser(_, _ int64) error { return nil } + +func (m *MemStore) IsBlocked(blockerID, blockedID int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.blocks[blockerID] != nil && m.blocks[blockerID][blockedID] { + return true, nil + } + return false, nil +} + +func (m *MemStore) IsEitherBlocked(userA, userB int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.blocks[userA] != nil && m.blocks[userA][userB] { + return true, nil + } + if m.blocks[userB] != nil && m.blocks[userB][userA] { + return true, nil + } + return false, nil +} + +func (m *MemStore) ListBlockedUsers(_ int64) ([]int64, error) { + return []int64{}, nil +} + +// ---------- AttachmentStore ---------- + +func (m *MemStore) CreateAttachment(_ string, _ int64, _, _, _ string, _ int64, _, _ *int) error { + panic("memstore: not implemented: CreateAttachment") +} + +func (m *MemStore) GetAttachmentByID(_ string) (*db.Attachment, error) { + panic("memstore: not implemented: GetAttachmentByID") +} + +func (m *MemStore) GetAttachmentWithChannel(_ string) (*db.AttachmentAccess, error) { + panic("memstore: not implemented: GetAttachmentWithChannel") +} + +func (m *MemStore) DeleteOrphanedAttachments(_ string) ([]string, error) { + panic("memstore: not implemented: DeleteOrphanedAttachments") +} + +// ---------- AdminStore ---------- + +func (m *MemStore) UserCount() (int64, error) { + panic("memstore: not implemented: UserCount") +} + +func (m *MemStore) GetServerStats() (*db.ServerStats, error) { + panic("memstore: not implemented: GetServerStats") +} + +func (m *MemStore) ListAllUsers(_ int, _ int) ([]db.UserWithRole, error) { + panic("memstore: not implemented: ListAllUsers") +} + +func (m *MemStore) BanUser(_ int64, _ string, _ *time.Time) error { + panic("memstore: not implemented: BanUser") +} + +func (m *MemStore) UnbanUser(_ int64) error { + panic("memstore: not implemented: UnbanUser") +} + +func (m *MemStore) LogAudit(_ int64, _, _ string, _ int64, _ string) error { + return nil +} + +func (m *MemStore) GetAuditLog(_ int, _ int) ([]db.AuditEntry, error) { + panic("memstore: not implemented: GetAuditLog") +} + +func (m *MemStore) AdminCreateChannel(_, _, _, _ string, _ int) (int64, error) { + panic("memstore: not implemented: AdminCreateChannel") +} + +func (m *MemStore) AdminUpdateChannel(_ int64, _, _ string, _, _ int, _ bool) error { + panic("memstore: not implemented: AdminUpdateChannel") +} + +func (m *MemStore) AdminDeleteChannel(_ int64) error { + panic("memstore: not implemented: AdminDeleteChannel") +} + +func (m *MemStore) BackupTo(_ string) error { + panic("memstore: not implemented: BackupTo") +} + +func (m *MemStore) BackupToSafe(_, _ string) error { + panic("memstore: not implemented: BackupToSafe") +} + +func (m *MemStore) CountUsersWithoutTOTP() (int, error) { + panic("memstore: not implemented: CountUsersWithoutTOTP") +} + +// ---------- SettingsStore ---------- + +func (m *MemStore) GetSetting(_ string) (string, error) { + panic("memstore: not implemented: GetSetting") +} + +func (m *MemStore) SetSetting(_, _ string) error { + panic("memstore: not implemented: SetSetting") +} + +func (m *MemStore) GetAllSettings() (map[string]string, error) { + panic("memstore: not implemented: GetAllSettings") +}