Files
OwnCord/Server/ws/handlers.go
T
Claude 071426c0d8 feat(server,client): announcement channels (D1, closes A-2026-07-01)
Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.

Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.

Server:
- migrations/016: recreate the channel-type triggers to allow
  text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
  rejects posts to announcement channels from users lacking MANAGE_MESSAGES
  (SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
  GetChannelUnreadCounts (db) now include announcement channels alongside
  text, so they track unread/last-message like text channels.

Client:
- ChannelSidebar renders announcement channels with a megaphone icon
  (added to the icon set) instead of the '#' text prefix; they otherwise
  behave like text channels (already typed in ChannelType).

Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.

Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 16:00:18 +00:00

222 lines
7.1 KiB
Go

package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// HandleMessageForTest dispatches a raw WebSocket message from client c.
// Exported so ws_test package can invoke it directly without a real connection.
func (h *Hub) HandleMessageForTest(c *Client, raw []byte) {
h.handleMessage(c, raw)
}
// HandleVoiceLeaveForTest calls handleVoiceLeave directly, simulating a
// disconnect-triggered cleanup without an explicit voice_leave message.
// Exported for ws_test package use only.
func (h *Hub) HandleVoiceLeaveForTest(c *Client) {
h.handleVoiceLeave(context.Background(), c)
}
// handleMessage parses the envelope and dispatches to the appropriate handler.
func (h *Hub) handleMessage(c *Client, raw []byte) {
// Periodic session expiry check: every SessionCheckInterval messages,
// re-validate the session token. This catches sessions that are revoked or
// expire while the WebSocket connection is still open.
c.mu.Lock()
c.msgCount++
shouldCheck := c.msgCount >= SessionCheckInterval
if shouldCheck {
c.msgCount = 0
}
c.mu.Unlock()
if shouldCheck && c.tokenHash != "" {
result, dbErr := h.db.GetSessionWithBanStatus(c.tokenHash)
if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {
slog.Info("ws session expired, closing connection", "user_id", c.userID)
h.kickClient(c)
return
}
tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires}
if auth.IsEffectivelyBanned(tempUser) {
slog.Info("ws user banned, closing connection", "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
h.kickClient(c)
return
}
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
c.mu.Lock()
c.invalidCount++
count := c.invalidCount
c.mu.Unlock()
slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err, "invalid_count", count)
c.sendMsg(buildErrorMsg(ErrCodeInvalidJSON, "message must be valid JSON"))
if count >= 10 {
slog.Warn("ws too many invalid messages, closing connection", "user_id", c.userID, "invalid_count", count)
h.kickClient(c)
}
return
}
// Valid parse — reset consecutive invalid counter.
c.mu.Lock()
c.invalidCount = 0
c.mu.Unlock()
// Cap client-controlled fields before logging to prevent log injection
// and unbounded log entries.
msgType := env.Type
if len(msgType) > 64 {
msgType = msgType[:64]
}
reqID := env.ID
if len(reqID) > 64 {
reqID = reqID[:64]
}
// Request-scoped logger with correlation context.
reqLog := slog.With(
"user_id", c.userID,
"msg_type", msgType,
"req_id", reqID,
)
reqLog.Debug("ws ← client message")
// ── V2 dispatch (strangler fig) ──────────────────────────────────────
// Only attempt V2 parsing+dispatch if a V2 handler is registered for
// this type. This prevents the stricter V2 parser from rejecting
// payloads that V1 handlers handle leniently.
if h.registry.hasV2(env.Type) {
if ctor, ok := getCommandConstructor(env.Type); ok {
cmd, parseErr := ctor(c.userID, env.ID, env.Payload)
if parseErr != nil {
reqLog.Warn("ws command parse error", "err", parseErr)
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid payload"))
return
}
var username string
var avatar *string
if c.user != nil {
username = c.user.Username
avatar = c.user.Avatar
}
voiceChID, voiceJoinTok := c.getVoiceState()
info := ClientInfo{
UserID: c.userID,
Username: username,
Avatar: avatar,
RoleName: c.roleName,
ReqID: env.ID,
VoiceChannelID: voiceChID,
VoiceJoinToken: voiceJoinTok,
}
result, dispatched := h.registry.DispatchV2(c.ctx, cmd, info)
if !dispatched {
reqLog.Error("ws V2 handler registered but DispatchV2 returned false", "type", env.Type)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
return
}
if result.Error != nil {
if ce, ok := result.Error.(ClientError); ok {
c.sendMsg(buildErrorMsg(ce.Code, ce.Message))
} else {
reqLog.Error("ws handler internal error", "err", result.Error)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
}
return
}
// Apply client state mutations.
if result.SetChannelID != nil {
oldChID := c.getChannelID()
c.mu.Lock()
c.channelID = *result.SetChannelID
c.mu.Unlock()
// Update pub/sub channel topic subscriptions.
newChID := *result.SetChannelID
if oldChID != newChID {
if oldChID > 0 {
c.hub.pubsub.Unsubscribe(c, ChannelTopic(oldChID))
}
if newChID > 0 {
c.hub.pubsub.Subscribe(c, ChannelTopic(newChID))
}
}
}
if result.SetE2EEPubKey != nil {
c.setE2EEPubKey(*result.SetE2EEPubKey)
}
if result.SetVoiceJoinToken != nil {
chID := c.getVoiceChID()
if chID != 0 {
c.setVoiceState(chID, *result.SetVoiceJoinToken)
}
}
if result.Reply != nil {
c.sendMsg(result.Reply)
}
if len(result.Events) > 0 {
h.EmitEvents(result.Events)
}
return
}
}
// ── End V2 dispatch ──────────────────────────────────────────────────
if !h.registry.Dispatch(c.ctx, env.Type, h, c, env.ID, env.Payload) {
reqLog.Warn("ws handleMessage unknown type")
c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", msgType)))
}
}
// hasChannelPerm reports whether the client's role has all the given permission bits.
// Delegates to the unified permissions.Checker.
func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool {
if c.user == nil {
return false
}
role, err := h.db.GetRoleByID(c.user.RoleID)
if err != nil || role == nil {
return false
}
return h.permChecker.HasChannelPerm(role.Permissions, role.ID, channelID, perm)
}
// requireChannelPerm checks whether the client has the given permission on the
// channel. If not, it sends a FORBIDDEN error to the client and returns false.
// The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES").
func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool {
if h.hasChannelPerm(c, channelID, perm) {
return true
}
slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel)
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing "+permLabel+" permission"))
return false
}
// broadcastExcludeLow sends a message at low priority to all clients in the
// sender's channel EXCEPT the sender. Messages sent via this function are NOT
// stored in the replay ring buffer — they are ephemeral. This is correct for
// typing indicators (dropped on overflow instead of disconnecting) but would
// be incorrect for messages that should survive reconnection replay.
func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
if channelID == 0 {
h.pubsub.PublishLow(TopicGlobal, msg, excludeUserID)
return
}
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
}