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:
Claude
2026-04-05 20:42:32 +00:00
parent 524e964cfc
commit 1c5fbbc246
13 changed files with 1096 additions and 28 deletions
+5 -1
View File
@@ -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.
+172
View File
@@ -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
}
+695
View File
@@ -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
}
+151
View File
@@ -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
}
+30
View File
@@ -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,
}
}
+1 -1
View File
@@ -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{
+4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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{
+15 -15
View File
@@ -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()