diff --git a/Server/service/message.go b/Server/service/message.go index 4a76a569..9dd0a4d8 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -677,6 +677,19 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) return ids, nil } +// CanPost reports whether userID may post into channelID, applying the same +// checks as a real message send: channel permissions via the cached checker +// for regular channels; participant membership AND block status for DMs. +// Exists so gates outside the send flow (the plugin broadcast path) share +// exactly this policy instead of hand-rolling a weaker copy. +func (s *MessageService) CanPost(userID, channelID int64) error { + ch, err := s.st.GetChannel(channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + return s.checkSendPermission(userID, channelID, ch.Type == "dm") +} + // checkSendPermission validates send permission for DM and non-DM channels. func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error { if isDM { diff --git a/Server/store/memstore.go b/Server/store/memstore.go index da73d39a..1837c54a 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -673,7 +673,14 @@ func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { return ids, nil } -func (m *MemStore) GetDMRecipient(_ int64, _ int64) (*db.User, error) { +func (m *MemStore) GetDMRecipient(channelID, userID int64) (*db.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + for uid := range m.dmParticipants[channelID] { + if uid != userID { + return m.users[uid], nil + } + } return nil, nil } diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 4a8d98f6..618b9bd9 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -11,11 +11,12 @@ package ws import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) const MsgTypeChatCommand = "chat_command" @@ -100,32 +101,31 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } // requireChannelBroadcastAccess reports whether the client may post to -// channelID, mirroring the normal message-send permission path. DM channels are -// validated by participant membership; all other channels require -// READ_MESSAGES|SEND_MESSAGES. On failure it sends an error to the client and -// returns false. Routes through the shared permissions.Checker.RequireChannelAccess -// so DM handling matches the rest of the codebase. +// channelID, by delegating to the SAME service-layer check a real message +// send runs (MessageService.CanPost: cached channel permissions; DM +// membership AND DM blocks). The previous RequireChannelAccess route skipped +// the block check in its DM branch — a blocked user's plugin broadcast could +// reach the person who blocked them — and issued a raw GetRoleByID per +// broadcast, bypassing the permission cache. On failure it sends an error to +// the client and returns false. func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool { if c.user == nil { c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated")) return false } - ch, err := h.db.GetChannel(channelID) - if err != nil || ch == nil { - c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + if h.messageSvc == nil { + // No service wired (bare test hub) — fail closed rather than allow + // an ungated broadcast. + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable")) return false } - role, err := h.db.GetRoleByID(c.user.RoleID) - if err != nil || role == nil { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "role not found")) - return false - } - if accessErr := h.permChecker.RequireChannelAccess( - c.userID, role.Permissions, role.ID, ch.Type, channelID, - permissions.ReadMessages|permissions.SendMessages, - ); accessErr != nil { + if err := h.messageSvc.CanPost(c.userID, channelID); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return false + } slog.Warn("ws plugin broadcast permission denied", - "user_id", c.userID, "channel_id", channelID, "err", accessErr) + "user_id", c.userID, "channel_id", channelID, "err", err) c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel")) return false } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 48deda38..4710547a 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -43,6 +43,10 @@ type Hub struct { lkProcess *LiveKitProcess registry *HandlerRegistry permChecker *permissions.Checker + // messageSvc gates plugin broadcasts through the same posting policy as a + // real message send (permissions, DM membership, DM blocks). Nil only in + // bare test hubs; the broadcast gate fails closed then. + messageSvc *service.MessageService pubsub *PubSub // topic-based pub/sub for O(subscribers) broadcast topicLimiter *TopicRateLimiter // per-topic throughput caps @@ -117,6 +121,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * chatDeps.MessageSvc = svc.Messages presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages + h.messageSvc = svc.Messages } registerChatHandlers(reg, chatDeps)