Files
OwnCord/Server/ws/command.go
T
J3vb ba66629077 fix(ci): resolve all golangci-lint and ESLint failures on PR #1132
Client:
- ci.yml: patch auto-generated events.ts to rename Event -> _Event
  so @typescript-eslint/no-unused-vars does not fail on generated code

Server (gocritic):
- service/channel.go: rangeValCopy (line 74), elseif (line 174)
- service/message.go: rangeValCopy (line 648), elseif (lines 438, 496)
- ws/emit.go: caseOrder — ChannelEvent before BroadcastAllEvent
- ws/handlers_command_test.go: stringXbytes — use bytes.Equal
- ws/pubsub_test.go: stringXbytes — use bytes.Equal

Server (nilerr):
- service/channel.go: nolint:nilerr for intentional silent drops in HandleTyping

Server (gosec):
- plugin/host_ui.go: G703 nolint — path already sanitized above
- plugin/registry.go: G302 — tighten plugin file permissions 0o640 → 0o600
- ws/hub.go, ws/serve.go: G115 nolint — seq counters never reach MaxInt64

Server (unused/unparam):
- plugin/registry.go: nolint:unused for wazero-tagged module field
- telemetry/metrics.go: nolint:unused for otel-tagged resetAppMetricsForInit
- ws/command.go: nolint:unparam for map entries whose error return is always nil

Server (staticcheck ST1000/ST1020):
- Add blank line before package declarations in phase-comment files
  (api/plugins_handler.go, plugin/host_{commands,events,http}.go,
   telemetry/metrics.go, telemetry/middleware.go,
   telemetry/telemetry_default.go, ws/event_persister.go)
- Fix GlobalTracer/GlobalMeter doc comments to start with function name
2026-04-07 10:10:38 +02:00

480 lines
16 KiB
Go

package ws
import (
"encoding/json"
"fmt"
)
// Command is the minimal interface for all client-to-server commands.
type Command interface {
// Type returns the message type constant (e.g. MsgTypeChatSend).
Type() string
// UserID returns the authenticated user who sent this command.
UserID() int64
}
// ChannelScoped is an optional interface for commands targeting a channel.
type ChannelScoped interface {
ChannelID() int64
}
// ── Concrete command structs ────────────────────────────────────────────────
// PingCmd represents a client ping (heartbeat).
type PingCmd struct {
userID int64
}
func (c PingCmd) Type() string { return MsgTypePing }
func (c PingCmd) UserID() int64 { return c.userID }
// ChatSendCmd represents a chat_send message.
type ChatSendCmd struct {
userID int64
reqID string
channelID int64
content string
replyTo *int64
attachments []string
}
func (c ChatSendCmd) Type() string { return MsgTypeChatSend }
func (c ChatSendCmd) UserID() int64 { return c.userID }
func (c ChatSendCmd) ChannelID() int64 { return c.channelID }
func (c ChatSendCmd) ReqID() string { return c.reqID }
func (c ChatSendCmd) Content() string { return c.content }
func (c ChatSendCmd) ReplyTo() *int64 { return c.replyTo }
func (c ChatSendCmd) Attachments() []string {
dst := make([]string, len(c.attachments))
copy(dst, c.attachments)
return dst
}
// ChatEditCmd represents a chat_edit message.
type ChatEditCmd struct {
userID int64
reqID string
messageID int64
content string
}
func (c ChatEditCmd) Type() string { return MsgTypeChatEdit }
func (c ChatEditCmd) UserID() int64 { return c.userID }
func (c ChatEditCmd) ReqID() string { return c.reqID }
func (c ChatEditCmd) MessageID() int64 { return c.messageID }
func (c ChatEditCmd) Content() string { return c.content }
// ChatDeleteCmd represents a chat_delete message.
type ChatDeleteCmd struct {
userID int64
reqID string
messageID int64
}
func (c ChatDeleteCmd) Type() string { return MsgTypeChatDelete }
func (c ChatDeleteCmd) UserID() int64 { return c.userID }
func (c ChatDeleteCmd) ReqID() string { return c.reqID }
func (c ChatDeleteCmd) MessageID() int64 { return c.messageID }
// TypingStartCmd represents a typing_start message.
type TypingStartCmd struct {
userID int64
channelID int64
}
func (c TypingStartCmd) Type() string { return MsgTypeTypingStart }
func (c TypingStartCmd) UserID() int64 { return c.userID }
func (c TypingStartCmd) ChannelID() int64 { return c.channelID }
// PresenceUpdateCmd represents a presence_update message.
type PresenceUpdateCmd struct {
userID int64
status string
}
func (c PresenceUpdateCmd) Type() string { return MsgTypePresenceUpdate }
func (c PresenceUpdateCmd) UserID() int64 { return c.userID }
func (c PresenceUpdateCmd) Status() string { return c.status }
// ChannelFocusCmd represents a channel_focus message.
type ChannelFocusCmd struct {
userID int64
channelID int64
}
func (c ChannelFocusCmd) Type() string { return MsgTypeChannelFocus }
func (c ChannelFocusCmd) UserID() int64 { return c.userID }
func (c ChannelFocusCmd) ChannelID() int64 { return c.channelID }
// ReactionAddCmd represents a reaction_add message.
type ReactionAddCmd struct {
userID int64
messageID int64
emoji string
}
func (c ReactionAddCmd) Type() string { return MsgTypeReactionAdd }
func (c ReactionAddCmd) UserID() int64 { return c.userID }
func (c ReactionAddCmd) MessageID() int64 { return c.messageID }
func (c ReactionAddCmd) Emoji() string { return c.emoji }
// ReactionRemoveCmd represents a reaction_remove message.
type ReactionRemoveCmd struct {
userID int64
messageID int64
emoji string
}
func (c ReactionRemoveCmd) Type() string { return MsgTypeReactionRemove }
func (c ReactionRemoveCmd) UserID() int64 { return c.userID }
func (c ReactionRemoveCmd) MessageID() int64 { return c.messageID }
func (c ReactionRemoveCmd) Emoji() string { return c.emoji }
// VoiceJoinCmd represents a voice_join message.
type VoiceJoinCmd struct {
userID int64
channelID int64
}
func (c VoiceJoinCmd) Type() string { return MsgTypeVoiceJoin }
func (c VoiceJoinCmd) UserID() int64 { return c.userID }
func (c VoiceJoinCmd) ChannelID() int64 { return c.channelID }
// VoiceLeaveCmd represents a voice_leave message.
type VoiceLeaveCmd struct {
userID int64
}
func (c VoiceLeaveCmd) Type() string { return MsgTypeVoiceLeave }
func (c VoiceLeaveCmd) UserID() int64 { return c.userID }
// VoiceTokenRefreshCmd represents a voice_token_refresh message.
type VoiceTokenRefreshCmd struct {
userID int64
}
func (c VoiceTokenRefreshCmd) Type() string { return MsgTypeVoiceTokenRefresh }
func (c VoiceTokenRefreshCmd) UserID() int64 { return c.userID }
// VoiceMuteCmd represents a voice_mute message.
type VoiceMuteCmd struct {
userID int64
muted bool
}
func (c VoiceMuteCmd) Type() string { return MsgTypeVoiceMute }
func (c VoiceMuteCmd) UserID() int64 { return c.userID }
func (c VoiceMuteCmd) Muted() bool { return c.muted }
// VoiceDeafenCmd represents a voice_deafen message.
type VoiceDeafenCmd struct {
userID int64
deafened bool
}
func (c VoiceDeafenCmd) Type() string { return MsgTypeVoiceDeafen }
func (c VoiceDeafenCmd) UserID() int64 { return c.userID }
func (c VoiceDeafenCmd) Deafened() bool { return c.deafened }
// VoiceCameraCmd represents a voice_camera message.
type VoiceCameraCmd struct {
userID int64
enabled bool
}
func (c VoiceCameraCmd) Type() string { return MsgTypeVoiceCamera }
func (c VoiceCameraCmd) UserID() int64 { return c.userID }
func (c VoiceCameraCmd) Enabled() bool { return c.enabled }
// VoiceScreenshareCmd represents a voice_screenshare message.
type VoiceScreenshareCmd struct {
userID int64
enabled bool
}
func (c VoiceScreenshareCmd) Type() string { return MsgTypeVoiceScreenshare }
func (c VoiceScreenshareCmd) UserID() int64 { return c.userID }
func (c VoiceScreenshareCmd) Enabled() bool { return c.enabled }
// VoiceE2EEAnnounceCmd represents a voice_e2ee_announce message.
type VoiceE2EEAnnounceCmd struct {
userID int64
publicKey string
}
func (c VoiceE2EEAnnounceCmd) Type() string { return MsgTypeVoiceE2EEAnnounce }
func (c VoiceE2EEAnnounceCmd) UserID() int64 { return c.userID }
func (c VoiceE2EEAnnounceCmd) PublicKey() string { return c.publicKey }
// VoiceE2EEOfferCmd represents a voice_e2ee_offer message.
type VoiceE2EEOfferCmd struct {
userID int64
targetUserID int64
encryptedKey string
iv string
}
func (c VoiceE2EEOfferCmd) Type() string { return MsgTypeVoiceE2EEOffer }
func (c VoiceE2EEOfferCmd) UserID() int64 { return c.userID }
func (c VoiceE2EEOfferCmd) TargetUserID() int64 { return c.targetUserID }
func (c VoiceE2EEOfferCmd) EncryptedKey() string { return c.encryptedKey }
func (c VoiceE2EEOfferCmd) IV() string { return c.iv }
// ── Command constructors ────────────────────────────────────────────────────
// commandConstructors maps message types to functions that parse payloads
// into typed Commands. The userID and reqID come from the envelope and
// authenticated client; raw is the JSON payload body.
// Unexported to prevent accidental mutation; use getCommandConstructor for lookups.
var commandConstructors = map[string]func(userID int64, reqID string, raw json.RawMessage) (Command, error){
MsgTypePing: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type
return PingCmd{userID: userID}, nil
},
MsgTypeChatSend: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
Content string `json:"content"`
ReplyTo *int64 `json:"reply_to"`
Attachments []string `json:"attachments"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_send payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if len(p.Attachments) > 10 {
return nil, fmt.Errorf("too many attachments (max 10)")
}
// TODO: validate attachment URL scheme (require https://) to prevent
// javascript:, data:, or file: URLs from being stored and relayed.
for i, url := range p.Attachments {
if len(url) > 2048 {
return nil, fmt.Errorf("attachment[%d] URL too long (max 2048)", i)
}
}
attachments := make([]string, len(p.Attachments))
copy(attachments, p.Attachments)
return ChatSendCmd{
userID: userID,
reqID: reqID,
channelID: chID,
content: p.Content,
replyTo: p.ReplyTo,
attachments: attachments,
}, nil
},
MsgTypeChatEdit: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_edit payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ChatEditCmd{
userID: userID,
reqID: reqID,
messageID: msgID,
content: p.Content,
}, nil
},
MsgTypeChatDelete: func(userID int64, reqID string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid chat_delete payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ChatDeleteCmd{
userID: userID,
reqID: reqID,
messageID: msgID,
}, nil
},
MsgTypeTypingStart: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid typing_start payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return TypingStartCmd{userID: userID, channelID: chID}, nil
},
MsgTypePresenceUpdate: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid presence_update payload: %w", err)
}
return PresenceUpdateCmd{userID: userID, status: p.Status}, nil
},
MsgTypeChannelFocus: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid channel_focus payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return ChannelFocusCmd{userID: userID, channelID: chID}, nil
},
MsgTypeReactionAdd: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Emoji string `json:"emoji"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid reaction_add payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ReactionAddCmd{userID: userID, messageID: msgID, emoji: p.Emoji}, nil
},
MsgTypeReactionRemove: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
MessageID json.Number `json:"message_id"`
Emoji string `json:"emoji"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid reaction_remove payload: %w", err)
}
msgID, err := p.MessageID.Int64()
if err != nil {
return nil, fmt.Errorf("message_id must be integer: %w", err)
}
return ReactionRemoveCmd{userID: userID, messageID: msgID, emoji: p.Emoji}, nil
},
MsgTypeVoiceJoin: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
ChannelID json.Number `json:"channel_id"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_join payload: %w", err)
}
chID, err := p.ChannelID.Int64()
if err != nil {
return nil, fmt.Errorf("channel_id must be integer: %w", err)
}
if chID <= 0 {
return nil, fmt.Errorf("channel_id must be positive")
}
return VoiceJoinCmd{userID: userID, channelID: chID}, nil
},
MsgTypeVoiceLeave: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type
return VoiceLeaveCmd{userID: userID}, nil
},
MsgTypeVoiceTokenRefresh: func(userID int64, _ string, _ json.RawMessage) (Command, error) { //nolint:unparam // error always nil; signature dictated by map type
return VoiceTokenRefreshCmd{userID: userID}, nil
},
MsgTypeVoiceMute: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Muted bool `json:"muted"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_mute payload: %w", err)
}
return VoiceMuteCmd{userID: userID, muted: p.Muted}, nil
},
MsgTypeVoiceDeafen: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Deafened bool `json:"deafened"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_deafen payload: %w", err)
}
return VoiceDeafenCmd{userID: userID, deafened: p.Deafened}, nil
},
MsgTypeVoiceCamera: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_camera payload: %w", err)
}
return VoiceCameraCmd{userID: userID, enabled: p.Enabled}, nil
},
MsgTypeVoiceScreenshare: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_screenshare payload: %w", err)
}
return VoiceScreenshareCmd{userID: userID, enabled: p.Enabled}, nil
},
MsgTypeVoiceE2EEAnnounce: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
PublicKey string `json:"public_key"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_e2ee_announce payload: %w", err)
}
return VoiceE2EEAnnounceCmd{userID: userID, publicKey: p.PublicKey}, nil
},
MsgTypeVoiceE2EEOffer: func(userID int64, _ string, raw json.RawMessage) (Command, error) {
var p struct {
TargetUserID int64 `json:"target_user_id"`
EncryptedKey string `json:"encrypted_key"`
IV string `json:"iv"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid voice_e2ee_offer payload: %w", err)
}
return VoiceE2EEOfferCmd{
userID: userID,
targetUserID: p.TargetUserID,
encryptedKey: p.EncryptedKey,
iv: p.IV,
}, nil
},
}
// getCommandConstructor returns the constructor for a message type, if registered.
func getCommandConstructor(msgType string) (func(int64, string, json.RawMessage) (Command, error), bool) {
ctor, ok := commandConstructors[msgType]
return ctor, ok
}