2026-04-05 20:42:32 +00:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-08-07 21:20:48 +02:00
|
|
|
"html"
|
2026-04-05 20:42:32 +00:00
|
|
|
"unicode/utf8"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
2026-04-05 20:42:32 +00:00
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 (
|
2026-04-06 22:48:59 +02:00
|
|
|
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")
|
2026-04-05 20:42:32 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-04-06 22:48:59 +02:00
|
|
|
MessageID int64
|
|
|
|
|
Timestamp string
|
|
|
|
|
Content string // sanitized content
|
|
|
|
|
IsDM bool
|
|
|
|
|
Channel *db.Channel
|
2026-04-05 20:42:32 +00:00
|
|
|
|
|
|
|
|
// 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
|
2026-08-01 22:06:14 +02:00
|
|
|
// DMParticipants is the full participant list of the DM, viewer-neutral:
|
|
|
|
|
// it is read with viewerID 0, which matches nobody, so every status is
|
|
|
|
|
// already broadcast-collapsed (an invisible participant reads as offline).
|
|
|
|
|
// That is what makes it safe to reuse for every addressee — the ws layer
|
|
|
|
|
// turns it into a per-recipient dm_channel_open payload without re-deriving
|
|
|
|
|
// visibility. Nil when the participant read failed, in which case the
|
|
|
|
|
// caller falls back to the sender-only 1:1 shape.
|
|
|
|
|
DMParticipants []db.DMUser
|
|
|
|
|
// DMIsGroup mirrors channels.is_group for this DM. Carried alongside the
|
|
|
|
|
// participants because a group that people have left can have two members
|
|
|
|
|
// and must still render as a group.
|
|
|
|
|
DMIsGroup bool
|
2026-04-05 20:42:32 +00:00
|
|
|
|
|
|
|
|
// Attachment data for broadcast.
|
|
|
|
|
Attachments []db.AttachmentInfo
|
2026-08-01 22:06:14 +02:00
|
|
|
|
|
|
|
|
// Mentions is the resolved mentioned user ids (never nil) and
|
|
|
|
|
// MentionsEveryone an authorized @everyone/@here. Both are broadcast so
|
|
|
|
|
// clients highlight from server-resolved data instead of re-guessing.
|
|
|
|
|
Mentions []int64
|
|
|
|
|
MentionsEveryone bool
|
2026-08-28 06:54:32 +02:00
|
|
|
// MentionsHere reports that MentionsEveryone came from @here rather than
|
|
|
|
|
// @everyone (mentionSet.HereOnly — never both, @here only narrows when
|
|
|
|
|
// @everyone is absent). applyMentionCounts skips the mention-count bump
|
|
|
|
|
// for an @here reader with no live connection at send time; clients need
|
|
|
|
|
// this bit to tell that case apart from a plain @everyone, which reaches
|
|
|
|
|
// every reader regardless (OC-0271).
|
|
|
|
|
MentionsHere bool
|
2026-04-05 20:42:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-01 22:06:14 +02:00
|
|
|
|
|
|
|
|
// Mentions/MentionsEveryone are re-resolved from the edited content.
|
|
|
|
|
Mentions []int64
|
|
|
|
|
MentionsEveryone bool
|
2026-08-28 06:54:32 +02:00
|
|
|
// MentionsHere: see SendMessageResult.MentionsHere.
|
|
|
|
|
MentionsHere bool
|
2026-04-05 20:42:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteMessageResult contains the output of a successful message delete.
|
|
|
|
|
type DeleteMessageResult struct {
|
|
|
|
|
MessageID int64
|
|
|
|
|
ChannelID int64
|
|
|
|
|
IsDM bool
|
|
|
|
|
IsMod bool
|
|
|
|
|
// DM-specific.
|
|
|
|
|
ParticipantIDs []int64
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
// PurgeMessagesResult contains the output of a successful bulk delete.
|
|
|
|
|
// MessageIDs is newest-first and is empty (never nil) when nothing matched.
|
|
|
|
|
type PurgeMessagesResult struct {
|
|
|
|
|
ChannelID int64
|
|
|
|
|
MessageIDs []int64
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:42:32 +00:00
|
|
|
// 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 {
|
2026-07-19 16:33:58 +00:00
|
|
|
st Store
|
2026-04-05 20:42:32 +00:00
|
|
|
perms *PermissionService
|
|
|
|
|
limiter *auth.RateLimiter
|
2026-08-01 22:06:14 +02:00
|
|
|
// bg runs mention-badge bookkeeping off the send path so a mention or
|
|
|
|
|
// @everyone message does not wait on the full reader-resolution chain
|
|
|
|
|
// before it is delivered to the rest of the channel. Defaults to `go fn()`;
|
|
|
|
|
// tests swap it for an inline runner via RunBackgroundInlineForTest so they
|
|
|
|
|
// can read the counts deterministically right after a send.
|
|
|
|
|
bg func(fn func())
|
2026-08-20 20:45:30 +02:00
|
|
|
// online reports whether userID currently holds a live connection. It is
|
|
|
|
|
// wired by the ws layer (Hub.IsUserConnected) after both are constructed,
|
|
|
|
|
// so @here can apply the same "no live connection is offline, whatever the
|
|
|
|
|
// row stores" rule the read path uses (ws/serve_ready.go
|
|
|
|
|
// presentableMembers) instead of trusting users.status alone — that column
|
|
|
|
|
// keeps a *chosen* idle/dnd/invisible across a disconnect by design
|
|
|
|
|
// (MarkUserDisconnected only ever rewrites "online" -> "offline"), so a
|
|
|
|
|
// disconnected idle/dnd reader would otherwise still collect an @here
|
|
|
|
|
// badge. nil (the zero value, e.g. in tests and any caller with no hub)
|
|
|
|
|
// means "no live-connection information available" and applies no extra
|
|
|
|
|
// narrowing, preserving prior behavior.
|
|
|
|
|
online func(userID int64) bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetOnlineChecker wires the live-connection predicate @here's offline
|
|
|
|
|
// narrowing consults in addition to users.status. Passing nil clears it. Safe
|
|
|
|
|
// to call once at startup (the ws layer, after constructing both the Hub and
|
|
|
|
|
// the Services) or from a test.
|
|
|
|
|
func (s *MessageService) SetOnlineChecker(online func(userID int64) bool) {
|
|
|
|
|
s.online = online
|
2026-04-05 20:42:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewMessageService creates a MessageService.
|
2026-07-19 16:33:58 +00:00
|
|
|
func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService {
|
2026-04-05 20:42:32 +00:00
|
|
|
return &MessageService{
|
2026-04-05 21:01:29 +00:00
|
|
|
st: st,
|
2026-04-05 20:42:32 +00:00
|
|
|
perms: perms,
|
|
|
|
|
limiter: limiter,
|
2026-08-01 22:06:14 +02:00
|
|
|
bg: func(fn func()) { go fn() },
|
2026-04-05 20:42:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
// RunBackgroundInlineForTest makes deferred bookkeeping (mention counts) run
|
|
|
|
|
// synchronously on the calling goroutine instead of in a background goroutine,
|
|
|
|
|
// so tests can assert on the results immediately after SendMessage returns.
|
|
|
|
|
// Test-only.
|
|
|
|
|
func (s *MessageService) RunBackgroundInlineForTest() {
|
|
|
|
|
s.bg = func(fn func()) { fn() }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// sanitizePass is one unescape-sanitize-unescape cycle. Both unescapes are
|
|
|
|
|
// load-bearing; do not drop either.
|
|
|
|
|
// - Inner: bluemonday's StrictPolicy treats "<img ...>" as inert
|
|
|
|
|
// escaped text and lets it through unchanged. Unescaping first turns
|
|
|
|
|
// smuggled entity-encoded markup into real markup *before* bluemonday
|
|
|
|
|
// sees it, so StrictPolicy actually strips it.
|
|
|
|
|
// - Outer: bluemonday writes surviving text tokens through
|
|
|
|
|
// html.EscapeString (Sanitize output is always HTML-escaped), so
|
|
|
|
|
// without this, plain punctuation like don't/>/"/& would be persisted
|
|
|
|
|
// and rendered as literal '/>/"/& entities.
|
|
|
|
|
func sanitizePass(s string) string {
|
|
|
|
|
return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sanitizeToFixpoint repeats sanitizePass until it stops changing the
|
|
|
|
|
// string. One pass is not enough on its own, for two reasons:
|
|
|
|
|
// - A multiply-encoded payload like "&lt;script&gt;" only has its
|
|
|
|
|
// outermost layer peeled by a single inner unescape, so it survives the
|
|
|
|
|
// first pass as still-escaped inert text and the *outer* unescape then
|
|
|
|
|
// turns the one remaining layer into a live "<script>". A second pass
|
|
|
|
|
// unescapes and strips it for real.
|
|
|
|
|
// - bluemonday's StrictPolicy is only self-stable in *escaped* space (its
|
|
|
|
|
// tokenizer decodes entities internally while reading a text node, then
|
|
|
|
|
// re-escapes uniformly on write, so Sanitize(Sanitize(x)) == Sanitize(x)
|
|
|
|
|
// always held, which is why the pre-fix code's fuzz idempotency check
|
|
|
|
|
// never caught this). Emitting a literal outer-unescaped "<" breaks
|
|
|
|
|
// that self-stability: e.g. bluemonday tag-strips the adversarial input
|
|
|
|
|
// "<<script>script>alert(1)<</script>/script>" down to escaped text
|
|
|
|
|
// "</script>", and one outer unescape turns that into the literal
|
|
|
|
|
// substring "</script>" — inert as *this* pass's output, but a real end
|
|
|
|
|
// tag if it were ever sanitized again. Looping to a fixpoint here means
|
|
|
|
|
// sanitizeContent's own output is always already stable, so re-running
|
|
|
|
|
// it (which the edit path effectively does, on separately-submitted
|
|
|
|
|
// content) is a true no-op instead of merely "safe once".
|
|
|
|
|
//
|
|
|
|
|
// It always terminates: bluemonday only ever removes characters or shortens
|
|
|
|
|
// escaped entities back down, so each pass's output length is
|
|
|
|
|
// non-increasing. The iteration bound is a defensive backstop pathological
|
|
|
|
|
// input can't actually reach, not what makes this safe.
|
|
|
|
|
func sanitizeToFixpoint(raw string) string {
|
|
|
|
|
s := raw
|
|
|
|
|
for i := 0; i <= len(raw); i++ {
|
|
|
|
|
next := sanitizePass(s)
|
|
|
|
|
if next == s {
|
|
|
|
|
return next
|
|
|
|
|
}
|
|
|
|
|
s = next
|
|
|
|
|
}
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 14:49:27 +02:00
|
|
|
// SanitizeText runs raw through the same unescape-sanitize-fixpoint pipeline
|
|
|
|
|
// as message content and profile free-text fields (see sanitizeToFixpoint):
|
|
|
|
|
// it strips HTML but leaves survivors as typed instead of persisting them as
|
|
|
|
|
// literal '/>/& entities the way a bare bluemonday sanitizer.Sanitize
|
|
|
|
|
// call would. Exported for call sites outside this package that sanitize a
|
|
|
|
|
// single free-text field before storage — e.g. the username field on
|
|
|
|
|
// registration, which must canonicalize identically to how lookups treat it.
|
|
|
|
|
func SanitizeText(raw string) string {
|
|
|
|
|
return sanitizeToFixpoint(raw)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:42:32 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
content := sanitizeToFixpoint(raw)
|
2026-04-05 20:42:32 +00:00
|
|
|
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
|
|
|
|
|
}
|