Files
OwnCord/Server/ws/event.go
T
J3vb 6e4a007b91 refactor: migrate WS handlers to V2 Command/Event architecture
Strangler-fig migration of 15 WebSocket handlers from V1 (Hub method,
*Client) to V2 (pure functions: Command, ClientInfo, deps -> Result).
V2 handlers are testable without a running Hub and produce declarative
Result values that the dispatch loop applies.

New abstractions:
- Command interface + typed constructors with input validation
- 7 Event routing interfaces (Channel, ExcludeSender, SequencedDM,
  UserTargeted, BroadcastAll, VoiceChannel, VoiceChannelGuarded)
- Per-domain deps structs (PingDeps, ChatDeps, PresenceDeps,
  ReactionDeps, VoiceDeps) with interface-based DI
- EmitEvents router matching events to delivery mechanisms
- DispatchV2 with panic recovery and runtime.Stack logging

Security hardening:
- Pre-sanitize byte length guard before bluemonday (DoS prevention)
- GetRoleForUser single-JOIN query avoids password hash on hot path
- channel_id positivity enforced in all command constructors
- Log injection prevention: msgType/reqID capped to 64 chars
- Nil KeyHolder dep returns ErrCodeInternal (not silent bypass)
- VoiceChannelGuardedEvent atomic check-and-send under h.mu.RLock

V1-only (complex state/mutex requirements): voice_join, voice_leave.

All tests pass with -race. No CI regressions expected.
2026-04-05 19:03:22 +02:00

295 lines
11 KiB
Go

package ws
// ClientError represents an error to send back to the requesting client.
// It implements the error interface so it can be used as Result.Error.
type ClientError struct {
Code string
Message string
}
func (e ClientError) Error() string { return e.Code + ": " + e.Message }
// Result is returned by V2 handlers. It describes the outcome of processing
// a Command: zero or more Events to emit, an optional error, and an optional
// Reply (ACK) to send back to the sender.
type Result struct {
// Events to route to other clients via EmitEvents.
Events []Event
// Error, if non-nil, is sent to the client. Use ClientError for
// user-facing errors; other error types are treated as internal.
Error error
// Reply is an optional raw JSON ACK sent only to the sender
// (e.g. chat_send_ok with the new message ID).
Reply []byte
// SetChannelID, if non-nil, updates the client's focused channel.
// Used by channel_focus to mutate client state from a V2 handler.
SetChannelID *int64
// SetE2EEPubKey, if non-nil, stores the ECDH public key on the client.
// Used by voice_e2ee_announce to persist the key for later retrieval.
SetE2EEPubKey *string
// SetVoiceJoinToken, if non-nil, caches the voice join token on the client.
// Used by voice_token_refresh when falling back to the DB for the token.
SetVoiceJoinToken *string
}
// Event is the base interface for all server-to-client events.
type Event interface {
// EventType returns the outbound message type constant (e.g. MsgTypeChatMessage).
EventType() string
}
// ── Routing interfaces ──────────────────────────────────────────────────────
// EmitEvents will type-switch on these interfaces to decide how to deliver
// each Event. The check order matters: SequencedDMEvent MUST be checked
// before ChannelEvent because DM events implement both.
// ChannelEvent routes to Hub.BroadcastToChannel (sequenced, replayable).
type ChannelEvent interface {
Event
ChannelID() int64
Payload() []byte
}
// ExcludeSenderEvent routes to Hub.broadcastExclude (ephemeral, not replayed).
// Used for typing indicators in non-DM channels.
type ExcludeSenderEvent interface {
Event
ChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// SequencedDMEvent routes to Hub.sendSequencedToUsers (sequenced, replayable).
// Used for chat messages, edits, deletes, and reactions in DM channels.
type SequencedDMEvent interface {
Event
ChannelID() int64
ParticipantIDs() []int64
Payload() []byte
}
// UserTargetedEvent routes to Hub.SendToUser (direct delivery to one user).
type UserTargetedEvent interface {
Event
TargetUserID() int64
Payload() []byte
}
// BroadcastAllEvent routes to Hub.BroadcastToAll (channelID=0, all clients).
type BroadcastAllEvent interface {
Event
Payload() []byte
}
// VoiceChannelEvent routes to Hub.sendToVoiceChannelExcept (ephemeral,
// targets voice channel participants excluding sender).
type VoiceChannelEvent interface {
Event
VoiceChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// VoiceChannelGuardedEvent routes to Hub.sendToUserIfInVoiceChannel —
// atomic check-and-send that verifies the target is still in the expected
// voice channel before delivering the message, all under a single h.mu.RLock.
// Used by voice_e2ee_offer to prevent TOCTOU races with concurrent voice_leave.
type VoiceChannelGuardedEvent interface {
Event
VoiceChannelID() int64
TargetUserID() int64
Payload() []byte
}
// ── Concrete event structs ──────────────────────────────────────────────────
// MessageSentChannelEvent is a chat message broadcast to a non-DM channel.
type MessageSentChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageSentChannelEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentChannelEvent) Payload() []byte { return e.payload }
// MessageSentDMEvent is a chat message broadcast to a DM channel's participants.
type MessageSentDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageSentDMEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageSentDMEvent) Payload() []byte { return e.payload }
// MessageEditedChannelEvent is a chat_edited broadcast to a non-DM channel.
type MessageEditedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageEditedChannelEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedChannelEvent) Payload() []byte { return e.payload }
// MessageEditedDMEvent is a chat_edited broadcast to DM participants.
type MessageEditedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageEditedDMEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageEditedDMEvent) Payload() []byte { return e.payload }
// MessageDeletedChannelEvent is a chat_deleted broadcast to a non-DM channel.
type MessageDeletedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageDeletedChannelEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedChannelEvent) Payload() []byte { return e.payload }
// MessageDeletedDMEvent is a chat_deleted broadcast to DM participants.
type MessageDeletedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageDeletedDMEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageDeletedDMEvent) Payload() []byte { return e.payload }
// TypingChannelEvent is a typing indicator broadcast to a channel, excluding sender.
type TypingChannelEvent struct {
channelID int64
excludeUserID int64
payload []byte
}
func (e TypingChannelEvent) EventType() string { return MsgTypeTyping }
func (e TypingChannelEvent) ChannelID() int64 { return e.channelID }
func (e TypingChannelEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e TypingChannelEvent) Payload() []byte { return e.payload }
// TypingDMEvent is a typing indicator sent to DM participants, excluding sender.
// It uses UserTargetedEvent routing because DM typing excludes the sender and
// is delivered directly to each other participant.
type TypingDMEvent struct {
targetUserID int64
payload []byte
}
func (e TypingDMEvent) EventType() string { return MsgTypeTyping }
func (e TypingDMEvent) TargetUserID() int64 { return e.targetUserID }
func (e TypingDMEvent) Payload() []byte { return e.payload }
// PresenceEvent is a presence update broadcast to all connected clients.
type PresenceEvent struct {
payload []byte
}
func (e PresenceEvent) EventType() string { return MsgTypePresence }
func (e PresenceEvent) Payload() []byte { return e.payload }
// ReactionChannelEvent is a reaction update broadcast to a non-DM channel.
type ReactionChannelEvent struct {
channelID int64
payload []byte
}
func (e ReactionChannelEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionChannelEvent) ChannelID() int64 { return e.channelID }
func (e ReactionChannelEvent) Payload() []byte { return e.payload }
// ReactionDMEvent is a reaction update broadcast to DM participants.
type ReactionDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e ReactionDMEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionDMEvent) ChannelID() int64 { return e.channelID }
func (e ReactionDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e ReactionDMEvent) Payload() []byte { return e.payload }
// VoiceStateEvent is a voice state broadcast to all connected clients.
type VoiceStateEvent struct {
payload []byte
}
func (e VoiceStateEvent) EventType() string { return MsgTypeVoiceState }
func (e VoiceStateEvent) Payload() []byte { return e.payload }
// VoiceLeaveEvent is a voice_leave broadcast to all connected clients.
// NOTE: Currently unused by V2 handlers — voice_leave remains V1 and emits
// via h.BroadcastToAll directly. Retained as forward-compatible scaffolding.
type VoiceLeaveEvent struct {
payload []byte
}
func (e VoiceLeaveEvent) EventType() string { return MsgTypeVoiceLeaveBC }
func (e VoiceLeaveEvent) Payload() []byte { return e.payload }
// VoiceE2EEAnnounceEvent relays an ECDH public key to other voice channel participants.
type VoiceE2EEAnnounceEvent struct {
voiceChannelID int64
excludeUserID int64
payload []byte
}
func (e VoiceE2EEAnnounceEvent) EventType() string { return MsgTypeVoiceE2EEAnnounceBC }
func (e VoiceE2EEAnnounceEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEAnnounceEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e VoiceE2EEAnnounceEvent) Payload() []byte { return e.payload }
// VoiceE2EEOfferGuardedEvent relays an encrypted room key to a specific user,
// using atomic check-and-send to verify the target is still in the same voice
// channel. Satisfies VoiceChannelGuardedEvent.
type VoiceE2EEOfferGuardedEvent struct {
voiceChannelID int64
targetUserID int64
payload []byte
}
func (e VoiceE2EEOfferGuardedEvent) EventType() string { return MsgTypeVoiceE2EEOfferRelay }
func (e VoiceE2EEOfferGuardedEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEOfferGuardedEvent) TargetUserID() int64 { return e.targetUserID }
func (e VoiceE2EEOfferGuardedEvent) Payload() []byte { return e.payload }
// DMChannelOpenEvent sends a dm_channel_open notification to a specific user.
type DMChannelOpenEvent struct {
targetUserID int64
payload []byte
}
func (e DMChannelOpenEvent) EventType() string { return MsgTypeDMChannelOpen }
func (e DMChannelOpenEvent) TargetUserID() int64 { return e.targetUserID }
func (e DMChannelOpenEvent) Payload() []byte { return e.payload }