mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
add service layer foundation (Phase A, Step 1)
Introduce Server/service/ package with MessageService, ChannelService, and PermissionService that encapsulate business logic previously scattered across REST and WS handlers. The PermissionService adds per-user in-memory caching with TTL-based expiry to eliminate per-message DB round-trips at scale. Services are wired into the WS hub via deps structs (strangler-fig pattern) — existing handlers continue to work unchanged, with service references available for incremental migration. https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/service"
|
||||
"github.com/owncord/server/storage"
|
||||
"github.com/owncord/server/updater"
|
||||
"github.com/owncord/server/ws"
|
||||
@@ -111,8 +112,11 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
||||
MountUploadRoutes(r, database, store, limiter, cfg.Server.AllowedOrigins)
|
||||
}
|
||||
|
||||
// Service layer — centralizes business logic for REST and WS handlers.
|
||||
svc := service.New(database, limiter)
|
||||
|
||||
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
getOnlineUsers = func() int { return hub.ClientCount() }
|
||||
|
||||
// Create LiveKit client if voice config is present; voice is disabled on failure.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// ChannelService handles channel-related business logic including
|
||||
// listing, permission-filtered access, typing, presence, and read state.
|
||||
type ChannelService struct {
|
||||
db *db.DB
|
||||
perms *PermissionService
|
||||
}
|
||||
|
||||
// NewChannelService creates a ChannelService.
|
||||
func NewChannelService(database *db.DB, perms *PermissionService) *ChannelService {
|
||||
return &ChannelService{
|
||||
db: database,
|
||||
perms: perms,
|
||||
}
|
||||
}
|
||||
|
||||
// ListVisibleChannels returns channels the user has ReadMessages permission for.
|
||||
// DM channels are excluded (they are accessed via DMService).
|
||||
func (s *ChannelService) ListVisibleChannels(userID int64) ([]db.Channel, error) {
|
||||
all, err := s.db.ListChannels()
|
||||
if err != nil {
|
||||
slog.Error("ChannelService.ListVisibleChannels", "err", err)
|
||||
return nil, fmt.Errorf("%w: failed to list channels", ErrInternal)
|
||||
}
|
||||
|
||||
role, err := s.perms.GetRoleForUser(userID)
|
||||
if err != nil || role == nil {
|
||||
slog.Error("ChannelService.ListVisibleChannels GetRoleForUser", "err", err, "user_id", userID)
|
||||
return nil, fmt.Errorf("%w: failed to get role", ErrInternal)
|
||||
}
|
||||
|
||||
isAdmin := permissions.HasAdmin(role.Permissions)
|
||||
if isAdmin {
|
||||
// Admin sees all non-DM channels.
|
||||
var visible []db.Channel
|
||||
for _, ch := range all {
|
||||
if ch.Type != "dm" {
|
||||
visible = append(visible, ch)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
overrides, err := s.db.GetAllChannelPermissionsForRole(role.ID)
|
||||
if err != nil {
|
||||
overrides = make(map[int64]db.ChannelOverride)
|
||||
}
|
||||
|
||||
var visible []db.Channel
|
||||
for _, ch := range all {
|
||||
if ch.Type == "dm" {
|
||||
continue
|
||||
}
|
||||
o := overrides[ch.ID]
|
||||
effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
|
||||
if effective&permissions.ReadMessages == permissions.ReadMessages {
|
||||
visible = append(visible, ch)
|
||||
}
|
||||
}
|
||||
|
||||
if visible == nil {
|
||||
visible = []db.Channel{}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// HandleTyping processes a typing start event for a channel.
|
||||
// Returns the channel so callers can build broadcast events.
|
||||
// Silent errors are returned as nil (typing indicators are best-effort).
|
||||
func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface {
|
||||
Allow(key string, limit int, window time.Duration) bool
|
||||
}) (*db.Channel, error) {
|
||||
if channelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Per-user-per-channel rate limit.
|
||||
ratKey := fmt.Sprintf("typing:%d:%d", userID, channelID)
|
||||
if limiter != nil && !limiter.Allow(ratKey, 1, 3*time.Second) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ch, err := s.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, nil // silent drop
|
||||
}
|
||||
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil || !ok {
|
||||
return nil, nil // silent drop
|
||||
}
|
||||
} else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) {
|
||||
return nil, nil // silent drop
|
||||
}
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// GetDMParticipantIDs returns the participant IDs for a DM channel.
|
||||
// Convenience method for handlers building DM events.
|
||||
func (s *ChannelService) GetDMParticipantIDs(channelID int64) ([]int64, error) {
|
||||
return s.db.GetDMParticipantIDs(channelID)
|
||||
}
|
||||
|
||||
// HandlePresenceUpdate validates and persists a presence status change.
|
||||
func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limiter interface {
|
||||
Allow(key string, limit int, window time.Duration) bool
|
||||
}) error {
|
||||
// Rate limit.
|
||||
ratKey := fmt.Sprintf("presence:%d", userID)
|
||||
if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) {
|
||||
return ErrRateLimited
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{
|
||||
"online": true, "idle": true, "dnd": true, "offline": true,
|
||||
}
|
||||
if !validStatuses[status] {
|
||||
return fmt.Errorf("%w: invalid status", ErrBadRequest)
|
||||
}
|
||||
|
||||
if err := s.db.UpdateUserStatus(userID, status); err != nil {
|
||||
slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID)
|
||||
return fmt.Errorf("%w: failed to update status", ErrInternal)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleChannelFocus processes a channel focus event and updates read state.
|
||||
// Returns the channel for callers to set client state.
|
||||
func (s *ChannelService) HandleChannelFocus(userID, channelID int64) (*db.Channel, error) {
|
||||
if channelID <= 0 {
|
||||
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
|
||||
}
|
||||
|
||||
ch, err := s.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, fmt.Errorf("%w: channel not found", ErrForbidden)
|
||||
}
|
||||
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
} else {
|
||||
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark channel as read.
|
||||
latestID, err := s.db.GetLatestMessageID(channelID)
|
||||
if err == nil && latestID > 0 {
|
||||
_ = s.db.UpdateReadState(userID, channelID, latestID)
|
||||
}
|
||||
|
||||
slog.Debug("channel_focus", "user_id", userID, "channel_id", channelID)
|
||||
return ch, nil
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// sanitizer is the shared HTML sanitization policy (strips all tags).
|
||||
var sanitizer = bluemonday.StrictPolicy()
|
||||
|
||||
// maxMessageLen is the maximum message length in runes.
|
||||
const maxMessageLen = 4000
|
||||
|
||||
// Common service-layer errors.
|
||||
var (
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrInternal = errors.New("internal error")
|
||||
ErrSlowMode = errors.New("slow mode")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrBlocked = errors.New("blocked")
|
||||
ErrDeletedMessage = errors.New("message is deleted")
|
||||
)
|
||||
|
||||
// SendMessageParams contains validated input for sending a message.
|
||||
type SendMessageParams struct {
|
||||
ChannelID int64
|
||||
UserID int64
|
||||
Username string
|
||||
Avatar *string
|
||||
RoleName string
|
||||
Content string // raw, will be sanitized
|
||||
ReplyTo *int64
|
||||
AttachmentIDs []string
|
||||
}
|
||||
|
||||
// SendMessageResult contains the output of a successful message send.
|
||||
type SendMessageResult struct {
|
||||
MessageID int64
|
||||
Timestamp string
|
||||
Content string // sanitized content
|
||||
IsDM bool
|
||||
Channel *db.Channel
|
||||
|
||||
// DM-specific fields populated when IsDM is true.
|
||||
ParticipantIDs []int64
|
||||
SenderUser *db.User // for dm_channel_open events
|
||||
OpenedDMFor []int64 // participant IDs that had their DM opened
|
||||
|
||||
// Attachment data for broadcast.
|
||||
Attachments []db.AttachmentInfo
|
||||
}
|
||||
|
||||
// EditMessageResult contains the output of a successful message edit.
|
||||
type EditMessageResult struct {
|
||||
MessageID int64
|
||||
ChannelID int64
|
||||
Content string
|
||||
EditedAt string
|
||||
IsDM bool
|
||||
// DM-specific.
|
||||
ParticipantIDs []int64
|
||||
}
|
||||
|
||||
// DeleteMessageResult contains the output of a successful message delete.
|
||||
type DeleteMessageResult struct {
|
||||
MessageID int64
|
||||
ChannelID int64
|
||||
IsDM bool
|
||||
IsMod bool
|
||||
// DM-specific.
|
||||
ParticipantIDs []int64
|
||||
}
|
||||
|
||||
// ReactionResult contains the output of a reaction add/remove.
|
||||
type ReactionResult struct {
|
||||
MessageID int64
|
||||
ChannelID int64
|
||||
UserID int64
|
||||
Emoji string
|
||||
Action string // "add" or "remove"
|
||||
IsDM bool
|
||||
// DM-specific.
|
||||
ParticipantIDs []int64
|
||||
}
|
||||
|
||||
// MessageService handles message-related business logic including
|
||||
// send, edit, delete, reactions, pins, and search.
|
||||
type MessageService struct {
|
||||
db *db.DB
|
||||
perms *PermissionService
|
||||
limiter *auth.RateLimiter
|
||||
}
|
||||
|
||||
// NewMessageService creates a MessageService.
|
||||
func NewMessageService(database *db.DB, perms *PermissionService, limiter *auth.RateLimiter) *MessageService {
|
||||
return &MessageService{
|
||||
db: database,
|
||||
perms: perms,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage validates, persists, and prepares broadcast data for a new message.
|
||||
// Callers are responsible for emitting the appropriate events.
|
||||
func (s *MessageService) SendMessage(p SendMessageParams) (*SendMessageResult, error) {
|
||||
// Rate limit.
|
||||
ratKey := fmt.Sprintf("chat:%d", p.UserID)
|
||||
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
|
||||
if p.ChannelID <= 0 {
|
||||
return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest)
|
||||
}
|
||||
|
||||
ch, err := s.db.GetChannel(p.ChannelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
||||
}
|
||||
|
||||
isDM := ch.Type == "dm"
|
||||
|
||||
// Permission check.
|
||||
if err := s.checkSendPermission(p.UserID, p.ChannelID, isDM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Slow mode (non-DM only).
|
||||
if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.ManageMessages) {
|
||||
slowKey := fmt.Sprintf("slow:%d:%d", p.UserID, p.ChannelID)
|
||||
if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
|
||||
return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and sanitize content.
|
||||
content, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attachment permission (non-DM).
|
||||
if !isDM && len(p.AttachmentIDs) > 0 {
|
||||
if !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.AttachFiles) {
|
||||
return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist message.
|
||||
msgID, err := s.db.CreateMessage(p.ChannelID, p.UserID, content, p.ReplyTo)
|
||||
if err != nil {
|
||||
slog.Error("MessageService.SendMessage CreateMessage", "err", err)
|
||||
return nil, fmt.Errorf("%w: failed to save message", ErrInternal)
|
||||
}
|
||||
|
||||
// Link attachments.
|
||||
var attachments []db.AttachmentInfo
|
||||
if len(p.AttachmentIDs) > 0 {
|
||||
linked, linkErr := s.db.LinkAttachmentsToMessage(msgID, p.AttachmentIDs)
|
||||
if linkErr != nil {
|
||||
slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID)
|
||||
// Cleanup: soft-delete the message.
|
||||
if delErr := s.db.DeleteMessage(msgID, p.UserID, true); delErr != nil {
|
||||
slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal)
|
||||
}
|
||||
if linked > 0 {
|
||||
attMap, attErr := s.db.GetAttachmentsByMessageIDs([]int64{msgID})
|
||||
if attErr != nil {
|
||||
slog.Error("MessageService.SendMessage GetAttachments", "err", attErr)
|
||||
} else {
|
||||
attachments = attMap[msgID]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch message for timestamp.
|
||||
msg, err := s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
slog.Error("MessageService.SendMessage GetMessage after create", "err", err)
|
||||
return nil, fmt.Errorf("%w: failed to retrieve message", ErrInternal)
|
||||
}
|
||||
|
||||
result := &SendMessageResult{
|
||||
MessageID: msgID,
|
||||
Timestamp: msg.Timestamp,
|
||||
Content: content,
|
||||
IsDM: isDM,
|
||||
Channel: ch,
|
||||
Attachments: attachments,
|
||||
}
|
||||
|
||||
// DM path: open DM for recipients.
|
||||
if isDM {
|
||||
participantIDs, pErr := s.db.GetDMParticipantIDs(p.ChannelID)
|
||||
if pErr != nil {
|
||||
slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID)
|
||||
return result, nil // Message saved, skip DM side effects.
|
||||
}
|
||||
result.ParticipantIDs = participantIDs
|
||||
|
||||
sender, _ := s.db.GetUserByID(p.UserID)
|
||||
result.SenderUser = sender
|
||||
|
||||
for _, pid := range participantIDs {
|
||||
if pid == p.UserID {
|
||||
continue
|
||||
}
|
||||
if openErr := s.db.OpenDM(pid, p.ChannelID); openErr != nil {
|
||||
slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID)
|
||||
continue
|
||||
}
|
||||
result.OpenedDMFor = append(result.OpenedDMFor, pid)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("message sent", "user", p.Username, "channel_id", p.ChannelID, "msg_id", msgID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EditMessage validates and persists a message edit.
|
||||
func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*EditMessageResult, error) {
|
||||
// Rate limit.
|
||||
ratKey := fmt.Sprintf("chat_edit:%d", userID)
|
||||
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
|
||||
if msgID <= 0 {
|
||||
return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest)
|
||||
}
|
||||
|
||||
content, err := sanitizeContent(rawContent, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch message.
|
||||
msg, err := s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
||||
}
|
||||
if msg.Deleted {
|
||||
return nil, fmt.Errorf("%w: cannot edit this message", ErrDeletedMessage)
|
||||
}
|
||||
|
||||
// Channel type for DM-aware permissions.
|
||||
ch, chErr := s.db.GetChannel(msg.ChannelID)
|
||||
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
||||
|
||||
if isDM {
|
||||
ok, dmErr := s.db.IsDMParticipant(userID, msg.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
||||
}
|
||||
} else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages) {
|
||||
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
||||
}
|
||||
|
||||
// EditMessage checks ownership internally.
|
||||
if err := s.db.EditMessage(msgID, userID, content); err != nil {
|
||||
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
||||
}
|
||||
|
||||
// Re-fetch for updated edited_at timestamp.
|
||||
msg, err = s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
slog.Error("MessageService.EditMessage GetMessage after edit", "err", err, "msg_id", msgID)
|
||||
return nil, fmt.Errorf("%w: edit saved but broadcast failed", ErrInternal)
|
||||
}
|
||||
|
||||
editedAt := ""
|
||||
if msg.EditedAt != nil {
|
||||
editedAt = *msg.EditedAt
|
||||
}
|
||||
|
||||
result := &EditMessageResult{
|
||||
MessageID: msgID,
|
||||
ChannelID: msg.ChannelID,
|
||||
Content: content,
|
||||
EditedAt: editedAt,
|
||||
IsDM: isDM,
|
||||
}
|
||||
|
||||
if isDM {
|
||||
participantIDs, pErr := s.db.GetDMParticipantIDs(msg.ChannelID)
|
||||
if pErr != nil {
|
||||
slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
||||
} else {
|
||||
result.ParticipantIDs = participantIDs
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteMessage validates and soft-deletes a message.
|
||||
func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResult, error) {
|
||||
// Rate limit.
|
||||
ratKey := fmt.Sprintf("chat_delete:%d", userID)
|
||||
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
|
||||
if msgID <= 0 {
|
||||
return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest)
|
||||
}
|
||||
|
||||
msg, err := s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
|
||||
ch, chErr := s.db.GetChannel(msg.ChannelID)
|
||||
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
||||
|
||||
if isDM {
|
||||
ok, dmErr := s.db.IsDMParticipant(userID, msg.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
} else {
|
||||
isMsgOwner := msg.UserID == userID
|
||||
canManage := s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages)
|
||||
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages))
|
||||
if !canDelete {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
isMod := !isDM && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages)
|
||||
if err := s.db.DeleteMessage(msgID, userID, isMod); err != nil {
|
||||
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
||||
}
|
||||
|
||||
slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
|
||||
_ = s.db.LogAudit(userID, "message_delete", "message", msgID,
|
||||
fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod))
|
||||
|
||||
result := &DeleteMessageResult{
|
||||
MessageID: msgID,
|
||||
ChannelID: msg.ChannelID,
|
||||
IsDM: isDM,
|
||||
IsMod: isMod,
|
||||
}
|
||||
|
||||
if isDM {
|
||||
participantIDs, pErr := s.db.GetDMParticipantIDs(msg.ChannelID)
|
||||
if pErr != nil {
|
||||
slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
||||
} else {
|
||||
result.ParticipantIDs = participantIDs
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddReaction adds a reaction to a message.
|
||||
func (s *MessageService) AddReaction(userID, msgID int64, emoji string) (*ReactionResult, error) {
|
||||
return s.handleReaction(userID, msgID, emoji, true)
|
||||
}
|
||||
|
||||
// RemoveReaction removes a reaction from a message.
|
||||
func (s *MessageService) RemoveReaction(userID, msgID int64, emoji string) (*ReactionResult, error) {
|
||||
return s.handleReaction(userID, msgID, emoji, false)
|
||||
}
|
||||
|
||||
func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add bool) (*ReactionResult, error) {
|
||||
// Rate limit.
|
||||
ratKey := fmt.Sprintf("reaction:%d", userID)
|
||||
if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
|
||||
if msgID <= 0 {
|
||||
return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest)
|
||||
}
|
||||
if emoji == "" || len([]rune(emoji)) > 32 {
|
||||
return nil, fmt.Errorf("%w: invalid emoji", ErrBadRequest)
|
||||
}
|
||||
// Reject control characters.
|
||||
for _, r := range emoji {
|
||||
if r <= 0x1F || r == 0x7F {
|
||||
return nil, fmt.Errorf("%w: emoji contains control characters", ErrBadRequest)
|
||||
}
|
||||
}
|
||||
// Sanitize check.
|
||||
if sanitizer.Sanitize(emoji) != emoji {
|
||||
return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest)
|
||||
}
|
||||
|
||||
msg, err := s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
return nil, fmt.Errorf("%w: message not found", ErrForbidden)
|
||||
}
|
||||
if msg.Deleted {
|
||||
return nil, fmt.Errorf("%w: cannot react to deleted message", ErrDeletedMessage)
|
||||
}
|
||||
|
||||
ch, chErr := s.db.GetChannel(msg.ChannelID)
|
||||
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
||||
|
||||
if isDM {
|
||||
ok, dmErr := s.db.IsDMParticipant(userID, msg.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: not a DM participant", ErrForbidden)
|
||||
}
|
||||
} else {
|
||||
if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.AddReactions) {
|
||||
return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
action := "add"
|
||||
if add {
|
||||
if err := s.db.AddReaction(msgID, userID, emoji); err != nil {
|
||||
slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID)
|
||||
return nil, fmt.Errorf("%w: reaction already exists", ErrConflict)
|
||||
}
|
||||
} else {
|
||||
action = "remove"
|
||||
if err := s.db.RemoveReaction(msgID, userID, emoji); err != nil {
|
||||
slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID)
|
||||
return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
result := &ReactionResult{
|
||||
MessageID: msgID,
|
||||
ChannelID: msg.ChannelID,
|
||||
UserID: userID,
|
||||
Emoji: emoji,
|
||||
Action: action,
|
||||
IsDM: isDM,
|
||||
}
|
||||
|
||||
if isDM {
|
||||
participantIDs, pErr := s.db.GetDMParticipantIDs(msg.ChannelID)
|
||||
if pErr != nil {
|
||||
slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
||||
} else {
|
||||
result.ParticipantIDs = participantIDs
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMessages retrieves paginated messages for a channel with permission checks.
|
||||
func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) {
|
||||
if channelID <= 0 {
|
||||
return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
|
||||
}
|
||||
|
||||
ch, err := s.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound)
|
||||
}
|
||||
|
||||
// Permission check.
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil || !ok {
|
||||
return nil, false, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
} else {
|
||||
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) {
|
||||
return nil, false, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Fetch one extra to detect has_more.
|
||||
msgs, err := s.db.GetMessagesForAPI(channelID, before, limit+1, userID)
|
||||
if err != nil {
|
||||
slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID)
|
||||
return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal)
|
||||
}
|
||||
|
||||
hasMore := len(msgs) > limit
|
||||
if hasMore {
|
||||
msgs = msgs[:limit]
|
||||
}
|
||||
|
||||
return msgs, hasMore, nil
|
||||
}
|
||||
|
||||
// SearchMessages performs full-text search across accessible channels.
|
||||
func (s *MessageService) SearchMessages(userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Single-channel search.
|
||||
if channelID != nil && *channelID > 0 {
|
||||
ch, err := s.db.GetChannel(*channelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, *channelID)
|
||||
if err != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
} else if !s.perms.HasChannelPerm(userID, *channelID, permissions.ReadMessages) {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
results, err := s.db.SearchMessages(query, channelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: search failed", ErrInternal)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Global search: build accessible channel list.
|
||||
accessibleIDs, err := s.GetAccessibleChannelIDs(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(accessibleIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results, err := s.db.SearchMessagesInChannels(query, accessibleIDs, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: search failed", ErrInternal)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetPinnedMessages retrieves pinned messages for a channel.
|
||||
func (s *MessageService) GetPinnedMessages(userID, channelID int64) ([]db.MessageAPIResponse, error) {
|
||||
if channelID <= 0 {
|
||||
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
|
||||
}
|
||||
ch, err := s.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil || !ok {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
} else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) {
|
||||
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
msgs, err := s.db.GetPinnedMessages(channelID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to fetch pinned messages", ErrInternal)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// SetMessagePinned pins or unpins a message.
|
||||
func (s *MessageService) SetMessagePinned(userID, channelID, msgID int64, pinned bool) error {
|
||||
if channelID <= 0 || msgID <= 0 {
|
||||
return fmt.Errorf("%w: invalid IDs", ErrBadRequest)
|
||||
}
|
||||
ch, err := s.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
return fmt.Errorf("%w: channel not found", ErrNotFound)
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil || !ok {
|
||||
return fmt.Errorf("%w: access denied", ErrForbidden)
|
||||
}
|
||||
} else if !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) {
|
||||
return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden)
|
||||
}
|
||||
// Verify message belongs to this channel.
|
||||
msg, err := s.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil || msg.ChannelID != channelID {
|
||||
return fmt.Errorf("%w: message not found in this channel", ErrNotFound)
|
||||
}
|
||||
return s.db.SetMessagePinned(msgID, pinned)
|
||||
}
|
||||
|
||||
// GetAccessibleChannelIDs returns all channel IDs the user can read.
|
||||
func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) {
|
||||
channels, err := s.db.ListChannels()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to list channels", ErrInternal)
|
||||
}
|
||||
|
||||
role, err := s.perms.GetRoleForUser(userID)
|
||||
if err != nil || role == nil {
|
||||
return nil, fmt.Errorf("%w: failed to get role", ErrInternal)
|
||||
}
|
||||
|
||||
isAdmin := permissions.HasAdmin(role.Permissions)
|
||||
var overrides map[int64]db.ChannelOverride
|
||||
if !isAdmin {
|
||||
overrides, _ = s.db.GetAllChannelPermissionsForRole(role.ID)
|
||||
if overrides == nil {
|
||||
overrides = make(map[int64]db.ChannelOverride)
|
||||
}
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
for _, ch := range channels {
|
||||
if ch.Type == "dm" {
|
||||
continue
|
||||
}
|
||||
if isAdmin {
|
||||
ids = append(ids, ch.ID)
|
||||
continue
|
||||
}
|
||||
o := overrides[ch.ID]
|
||||
effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
|
||||
if effective&permissions.ReadMessages == permissions.ReadMessages {
|
||||
ids = append(ids, ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Also include DM channels the user participates in.
|
||||
dmChannels, err := s.db.GetUserDMChannels(userID)
|
||||
if err == nil {
|
||||
for _, dmc := range dmChannels {
|
||||
ids = append(ids, dmc.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// checkSendPermission validates send permission for DM and non-DM channels.
|
||||
func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error {
|
||||
if isDM {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to check DM participation", ErrInternal)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: not a participant in this DM", ErrForbidden)
|
||||
}
|
||||
recipient, err := s.db.GetDMRecipient(channelID, userID)
|
||||
if err == nil && recipient != nil {
|
||||
blocked, blkErr := s.db.IsEitherBlocked(userID, recipient.ID)
|
||||
if blkErr != nil {
|
||||
return fmt.Errorf("%w: failed to check block status", ErrInternal)
|
||||
}
|
||||
if blocked {
|
||||
return fmt.Errorf("%w: cannot send messages — user is blocked", ErrBlocked)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) {
|
||||
return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeContent validates and sanitizes message content.
|
||||
func sanitizeContent(raw string, allowEmpty bool) (string, error) {
|
||||
if len(raw) > maxMessageLen*4 {
|
||||
return "", fmt.Errorf("%w: message content exceeds maximum length", ErrBadRequest)
|
||||
}
|
||||
content := sanitizer.Sanitize(raw)
|
||||
if content == "" && !allowEmpty {
|
||||
return "", fmt.Errorf("%w: message content cannot be empty", ErrBadRequest)
|
||||
}
|
||||
if utf8.RuneCountInString(content) > maxMessageLen {
|
||||
return "", fmt.Errorf("%w: message content exceeds maximum length of %d characters", ErrBadRequest, maxMessageLen)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// cachedPerms holds a snapshot of a user's role and channel overrides.
|
||||
type cachedPerms struct {
|
||||
roleID int64
|
||||
rolePerms int64
|
||||
overrides map[int64]db.ChannelOverride
|
||||
populatedAt time.Time
|
||||
}
|
||||
|
||||
// permCacheTTL is how long cached permissions remain valid before refresh.
|
||||
const permCacheTTL = 30 * time.Second
|
||||
|
||||
// PermissionService wraps the stateless permissions.Checker with per-user
|
||||
// caching. It eliminates per-message DB round-trips for permission checks
|
||||
// at scale. The cache is populated lazily on first access and invalidated
|
||||
// on role or channel override changes.
|
||||
type PermissionService struct {
|
||||
db *db.DB
|
||||
checker *permissions.Checker
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[int64]*cachedPerms // keyed by userID
|
||||
}
|
||||
|
||||
// NewPermissionService creates a PermissionService backed by the given DB.
|
||||
func NewPermissionService(database *db.DB, checker *permissions.Checker) *PermissionService {
|
||||
return &PermissionService{
|
||||
db: database,
|
||||
checker: checker,
|
||||
cache: make(map[int64]*cachedPerms),
|
||||
}
|
||||
}
|
||||
|
||||
// HasChannelPerm reports whether the user has the required permission bits
|
||||
// on the given channel. Uses cached role/override data when available.
|
||||
func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool {
|
||||
cp := s.getOrPopulate(userID)
|
||||
if cp == nil {
|
||||
return false
|
||||
}
|
||||
if permissions.HasAdmin(cp.rolePerms) {
|
||||
return true
|
||||
}
|
||||
o := cp.overrides[channelID] // zero-value (0,0) when no override exists
|
||||
effective := permissions.EffectivePerms(cp.rolePerms, o.Allow, o.Deny)
|
||||
return effective&perm == perm
|
||||
}
|
||||
|
||||
// RequireChannelAccess checks whether the user can access the channel with
|
||||
// the given permission. For DM channels it verifies participant membership.
|
||||
// For regular channels it uses cached role-based permission checks.
|
||||
func (s *PermissionService) RequireChannelAccess(userID int64, channelType string, channelID, perm int64) error {
|
||||
if channelType == "dm" {
|
||||
ok, err := s.db.IsDMParticipant(userID, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return permissions.ErrNotDMParticipant
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !s.HasChannelPerm(userID, channelID, perm) {
|
||||
return permissions.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRoleForUser returns the user's role, using the cache when available.
|
||||
func (s *PermissionService) GetRoleForUser(userID int64) (*db.Role, error) {
|
||||
cp := s.getOrPopulate(userID)
|
||||
if cp == nil {
|
||||
// Cache miss, fall back to direct DB query.
|
||||
return s.db.GetRoleForUser(userID)
|
||||
}
|
||||
return s.db.GetRoleByID(cp.roleID)
|
||||
}
|
||||
|
||||
// InvalidateUser removes cached permissions for a specific user.
|
||||
// Call this when a user's role changes.
|
||||
func (s *PermissionService) InvalidateUser(userID int64) {
|
||||
s.mu.Lock()
|
||||
delete(s.cache, userID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateChannel removes cached permissions for ALL users, since a
|
||||
// channel override change can affect any user with that role.
|
||||
// Call this when channel_overrides are modified.
|
||||
func (s *PermissionService) InvalidateChannel(_ int64) {
|
||||
s.mu.Lock()
|
||||
s.cache = make(map[int64]*cachedPerms)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateAll clears the entire permission cache.
|
||||
func (s *PermissionService) InvalidateAll() {
|
||||
s.mu.Lock()
|
||||
s.cache = make(map[int64]*cachedPerms)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Checker returns the underlying stateless permissions.Checker for cases
|
||||
// where callers need direct access (e.g., batch channel filtering).
|
||||
func (s *PermissionService) Checker() *permissions.Checker {
|
||||
return s.checker
|
||||
}
|
||||
|
||||
// getOrPopulate returns cached perms for the user, populating the cache
|
||||
// on miss or staleness. Returns nil if the user's role can't be loaded.
|
||||
func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms {
|
||||
s.mu.RLock()
|
||||
cp, ok := s.cache[userID]
|
||||
if ok && time.Since(cp.populatedAt) < permCacheTTL {
|
||||
s.mu.RUnlock()
|
||||
return cp
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Populate.
|
||||
role, err := s.db.GetRoleForUser(userID)
|
||||
if err != nil || role == nil {
|
||||
return nil
|
||||
}
|
||||
overrides, err := s.db.GetAllChannelPermissionsForRole(role.ID)
|
||||
if err != nil {
|
||||
// Fall back to uncached if override fetch fails.
|
||||
overrides = make(map[int64]db.ChannelOverride)
|
||||
}
|
||||
|
||||
cp = &cachedPerms{
|
||||
roleID: role.ID,
|
||||
rolePerms: role.Permissions,
|
||||
overrides: overrides,
|
||||
populatedAt: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cache[userID] = cp
|
||||
s.mu.Unlock()
|
||||
return cp
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package service provides the domain service layer for OwnCord.
|
||||
// Services encapsulate business logic (validation, permission checks, DB operations)
|
||||
// that was previously scattered across REST and WebSocket handlers.
|
||||
// Both REST and WS handlers become thin adapters that call service methods.
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// Services bundles all domain services for dependency injection.
|
||||
// Handlers receive this struct instead of raw *db.DB references.
|
||||
type Services struct {
|
||||
Messages *MessageService
|
||||
Channels *ChannelService
|
||||
Permissions *PermissionService
|
||||
}
|
||||
|
||||
// New creates all domain services wired together.
|
||||
func New(database *db.DB, limiter *auth.RateLimiter) *Services {
|
||||
permChecker := permissions.NewChecker(database)
|
||||
permSvc := NewPermissionService(database, permChecker)
|
||||
return &Services{
|
||||
Messages: NewMessageService(database, permSvc, limiter),
|
||||
Channels: NewChannelService(database, permSvc),
|
||||
Permissions: permSvc,
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openCoverageDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
|
||||
// Inject a test LiveKit client so voice_join passes the livekit!=nil guard.
|
||||
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// ClientInfo holds a read-only snapshot of client state for V2 handlers.
|
||||
@@ -33,6 +34,7 @@ type ChatDeps struct {
|
||||
DB *db.DB
|
||||
Limiter *auth.RateLimiter
|
||||
Permissions *permissions.Checker
|
||||
MessageSvc *service.MessageService
|
||||
}
|
||||
|
||||
// PresenceDeps holds dependencies for presence, typing, and channel focus handlers.
|
||||
@@ -40,6 +42,7 @@ type PresenceDeps struct {
|
||||
DB *db.DB
|
||||
Limiter *auth.RateLimiter
|
||||
Permissions *permissions.Checker
|
||||
ChannelSvc *service.ChannelService
|
||||
}
|
||||
|
||||
// ReactionDeps holds dependencies for reaction handlers.
|
||||
@@ -47,6 +50,7 @@ type ReactionDeps struct {
|
||||
DB *db.DB
|
||||
Limiter *auth.RateLimiter
|
||||
Permissions *permissions.Checker
|
||||
MessageSvc *service.MessageService
|
||||
}
|
||||
|
||||
// VoiceTokenGenerator generates LiveKit access tokens. Abstracted so V2
|
||||
|
||||
@@ -71,7 +71,7 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openHandlerDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
return hub, database
|
||||
|
||||
+19
-7
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/service"
|
||||
"github.com/owncord/server/syncutil"
|
||||
)
|
||||
|
||||
@@ -60,7 +61,8 @@ type Hub struct {
|
||||
|
||||
// NewHub creates a Hub ready to be started with Run.
|
||||
// It also initializes the settings cache from the database.
|
||||
func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
|
||||
// If svc is non-nil, V2 handlers receive service references for business logic delegation.
|
||||
func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *Hub {
|
||||
reg := NewHandlerRegistry()
|
||||
registerVoiceHandlersV1(reg)
|
||||
|
||||
@@ -82,21 +84,31 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
|
||||
|
||||
// V2 handler registrations (need Hub fields for deps).
|
||||
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
|
||||
registerChatHandlers(reg, ChatDeps{
|
||||
|
||||
chatDeps := ChatDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
Permissions: h.permChecker,
|
||||
})
|
||||
registerPresenceHandlers(reg, PresenceDeps{
|
||||
}
|
||||
presenceDeps := PresenceDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
Permissions: h.permChecker,
|
||||
})
|
||||
registerReactionHandlers(reg, ReactionDeps{
|
||||
}
|
||||
reactionDeps := ReactionDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
Permissions: h.permChecker,
|
||||
})
|
||||
}
|
||||
if svc != nil {
|
||||
chatDeps.MessageSvc = svc.Messages
|
||||
presenceDeps.ChannelSvc = svc.Channels
|
||||
reactionDeps.MessageSvc = svc.Messages
|
||||
}
|
||||
|
||||
registerChatHandlers(reg, chatDeps)
|
||||
registerPresenceHandlers(reg, presenceDeps)
|
||||
registerReactionHandlers(reg, reactionDeps)
|
||||
registerVoiceControlsV2(reg, VoiceDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
|
||||
@@ -37,7 +37,7 @@ func newTestHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
return hub, database
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ func newServeHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
return hub, database
|
||||
|
||||
@@ -51,7 +51,7 @@ func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openVoiceTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
|
||||
// Inject a test LiveKit client with non-default credentials.
|
||||
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
func TestServeWS_InvalidUpgrade_ReturnsError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestServeWS_InvalidUpgrade_ReturnsError(t *testing.T) {
|
||||
func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) {
|
||||
func TestAuthenticateConn_InvalidJSON_ReceivesAuthError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestAuthenticateConn_InvalidJSON_ReceivesAuthError(t *testing.T) {
|
||||
func TestAuthenticateConn_WrongMessageType_ReceivesAuthError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestAuthenticateConn_WrongMessageType_ReceivesAuthError(t *testing.T) {
|
||||
func TestAuthenticateConn_MissingToken_ReceivesAuthError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestAuthenticateConn_MissingToken_ReceivesAuthError(t *testing.T) {
|
||||
func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -283,7 +283,7 @@ func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
|
||||
func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
|
||||
func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -440,7 +440,7 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) {
|
||||
func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -522,7 +522,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) {
|
||||
func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -679,7 +679,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) {
|
||||
func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -902,7 +902,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) {
|
||||
func TestServeWS_writePump_MessageDelivered(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -996,7 +996,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) {
|
||||
func TestIntegration_MessageRoundTrip(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -1142,7 +1142,7 @@ func TestIntegration_MessageRoundTrip(t *testing.T) {
|
||||
func TestIntegration_SequenceNumbers(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -1234,7 +1234,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) {
|
||||
func TestServeWS_BannedUser_ReceivesError(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user