mattermost/server/public/model/websocket_message.go
Felipe Martin 1be8a68dd7
feat: pluginapi: filewillbedownloaded / sendtoastmessage (#34596)
* feat: filewillbedonwloaded hook

* feat: error popup

* chore: make generated pluginapi

* tests

* feat: different errors for different download types

* feat: allow toast positions

* fix: avoid using deprecated i18n function

* feat: add plugin API to show toasts

* feat: downloadType parameter

* tests: updated tests

* chore: make check-style

* chore: i18n

* chore: missing fields in tests

* chore: sorted i18n for webapp

* chore: run mmjstool

* test: fixed webapp tests with new changes

* test: missing mocks

* fix: ensure one-file attachments (previews) are handler properly as thumbnails

* chore: lint

* test: added new logic to tests

* chore: lint

* Add SendToastMessage API and FileWillBeDownloaded hook

- Introduced SendToastMessage method for sending toast notifications to users with customizable options.
- Added FileWillBeDownloaded hook to handle file download requests, allowing plugins to control access to files.
- Updated related types and constants for file download handling.
- Enhanced PluginSettings to include HookTimeoutSeconds for better timeout management.

* Update webapp/channels/src/components/single_image_view/single_image_view.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: copilot reviews

* test: head requests

* chore: linted the webapp

* tests: fixed path

* test: fixed mocked args

* allow sending message to a connection directly

* fix: hook thread safety

* chore: formatting

* chore: remove configuration from system console

* chore: release version

* chore: update signature

* chore: update release version

* chore: addressed comments

* fix: update file rejection handling to use 403 Forbidden status and include rejection reason header

* Fix nil pointer panic in runFileWillBeDownloadedHook

The atomic.Value in runFileWillBeDownloadedHook can be nil if no
plugins implement the FileWillBeDownloaded hook. This causes a panic
when trying to assert the nil interface to string.

This fix adds a nil check before the type assertion, defaulting to
an empty string (which allows the download) when no hooks have run.

Fixes:
- TestUploadDataMultipart/success panic
- TestUploadDataMultipart/resume_success panic

* test: move the logout test last

* chore: restored accidential deletion

* chore: lint

* chore: make generated

* refactor: move websocket events to new package

* chore: go vet

* chore: missing mock

* chore: revert incorrect fmt

* chore: import ordering

* chore: npm i18n-extract

* chore: update constants.tsx from master

* chore: make i18n-extract

* revert: conflict merge

* fix: add missing isFileRejected prop to SingleImageView tests

* fix: mock fetch in SingleImageView tests for async thumbnail check

The component now performs an async fetch to check thumbnail availability
before rendering. Tests need to mock fetch and use waitFor to handle
the async state updates.

* refactor: move hook logic to app layer

* chore: update version to 11.5

* Scope file download rejection toast to the requesting connection

Thread the Connection-Id header through RunFileWillBeDownloadedHook and
sendFileDownloadRejectedEvent so the WebSocket event is sent only to the
connection that initiated the download, instead of all connections for
the user.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-16 17:10:39 +01:00

466 lines
19 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"io"
"maps"
"strconv"
)
type WebsocketEventType string
const (
WebsocketEventTyping WebsocketEventType = "typing"
WebsocketEventPosted WebsocketEventType = "posted"
WebsocketEventPostEdited WebsocketEventType = "post_edited"
WebsocketEventPostDeleted WebsocketEventType = "post_deleted"
WebsocketEventPostUnread WebsocketEventType = "post_unread"
WebsocketEventChannelConverted WebsocketEventType = "channel_converted"
WebsocketEventChannelCreated WebsocketEventType = "channel_created"
WebsocketEventChannelDeleted WebsocketEventType = "channel_deleted"
WebsocketEventChannelRestored WebsocketEventType = "channel_restored"
WebsocketEventChannelUpdated WebsocketEventType = "channel_updated"
WebsocketEventChannelMemberUpdated WebsocketEventType = "channel_member_updated"
WebsocketEventChannelSchemeUpdated WebsocketEventType = "channel_scheme_updated"
WebsocketEventDirectAdded WebsocketEventType = "direct_added"
WebsocketEventGroupAdded WebsocketEventType = "group_added"
WebsocketEventNewUser WebsocketEventType = "new_user"
WebsocketEventAddedToTeam WebsocketEventType = "added_to_team"
WebsocketEventLeaveTeam WebsocketEventType = "leave_team"
WebsocketEventUpdateTeam WebsocketEventType = "update_team"
WebsocketEventDeleteTeam WebsocketEventType = "delete_team"
WebsocketEventRestoreTeam WebsocketEventType = "restore_team"
WebsocketEventUpdateTeamScheme WebsocketEventType = "update_team_scheme"
WebsocketEventUserAdded WebsocketEventType = "user_added"
WebsocketEventUserUpdated WebsocketEventType = "user_updated"
WebsocketEventUserRoleUpdated WebsocketEventType = "user_role_updated"
WebsocketEventMemberroleUpdated WebsocketEventType = "memberrole_updated"
WebsocketEventUserRemoved WebsocketEventType = "user_removed"
WebsocketEventPreferenceChanged WebsocketEventType = "preference_changed"
WebsocketEventPreferencesChanged WebsocketEventType = "preferences_changed"
WebsocketEventPreferencesDeleted WebsocketEventType = "preferences_deleted"
WebsocketEventEphemeralMessage WebsocketEventType = "ephemeral_message"
WebsocketEventStatusChange WebsocketEventType = "status_change"
WebsocketEventHello WebsocketEventType = "hello"
WebsocketAuthenticationChallenge WebsocketEventType = "authentication_challenge"
WebsocketEventReactionAdded WebsocketEventType = "reaction_added"
WebsocketEventReactionRemoved WebsocketEventType = "reaction_removed"
WebsocketEventResponse WebsocketEventType = "response"
WebsocketEventEmojiAdded WebsocketEventType = "emoji_added"
WebsocketEventMultipleChannelsViewed WebsocketEventType = "multiple_channels_viewed"
WebsocketEventPluginStatusesChanged WebsocketEventType = "plugin_statuses_changed"
WebsocketEventPluginEnabled WebsocketEventType = "plugin_enabled"
WebsocketEventPluginDisabled WebsocketEventType = "plugin_disabled"
WebsocketEventRoleUpdated WebsocketEventType = "role_updated"
WebsocketEventLicenseChanged WebsocketEventType = "license_changed"
WebsocketEventConfigChanged WebsocketEventType = "config_changed"
WebsocketEventOpenDialog WebsocketEventType = "open_dialog"
WebsocketEventGuestsDeactivated WebsocketEventType = "guests_deactivated"
WebsocketEventUserActivationStatusChange WebsocketEventType = "user_activation_status_change"
WebsocketEventReceivedGroup WebsocketEventType = "received_group"
WebsocketEventReceivedGroupAssociatedToTeam WebsocketEventType = "received_group_associated_to_team"
WebsocketEventReceivedGroupNotAssociatedToTeam WebsocketEventType = "received_group_not_associated_to_team"
WebsocketEventReceivedGroupAssociatedToChannel WebsocketEventType = "received_group_associated_to_channel"
WebsocketEventReceivedGroupNotAssociatedToChannel WebsocketEventType = "received_group_not_associated_to_channel"
WebsocketEventGroupMemberDelete WebsocketEventType = "group_member_deleted"
WebsocketEventGroupMemberAdd WebsocketEventType = "group_member_add"
WebsocketEventSidebarCategoryCreated WebsocketEventType = "sidebar_category_created"
WebsocketEventSidebarCategoryUpdated WebsocketEventType = "sidebar_category_updated"
WebsocketEventSidebarCategoryDeleted WebsocketEventType = "sidebar_category_deleted"
WebsocketEventSidebarCategoryOrderUpdated WebsocketEventType = "sidebar_category_order_updated"
WebsocketEventCloudSubscriptionChanged WebsocketEventType = "cloud_subscription_changed"
WebsocketEventThreadUpdated WebsocketEventType = "thread_updated"
WebsocketEventThreadFollowChanged WebsocketEventType = "thread_follow_changed"
WebsocketEventThreadReadChanged WebsocketEventType = "thread_read_changed"
WebsocketFirstAdminVisitMarketplaceStatusReceived WebsocketEventType = "first_admin_visit_marketplace_status_received"
WebsocketEventDraftCreated WebsocketEventType = "draft_created"
WebsocketEventDraftUpdated WebsocketEventType = "draft_updated"
WebsocketEventDraftDeleted WebsocketEventType = "draft_deleted"
WebsocketEventAcknowledgementAdded WebsocketEventType = "post_acknowledgement_added"
WebsocketEventAcknowledgementRemoved WebsocketEventType = "post_acknowledgement_removed"
WebsocketEventPersistentNotificationTriggered WebsocketEventType = "persistent_notification_triggered"
WebsocketEventHostedCustomerSignupProgressUpdated WebsocketEventType = "hosted_customer_signup_progress_updated"
WebsocketEventChannelBookmarkCreated WebsocketEventType = "channel_bookmark_created"
WebsocketEventChannelBookmarkUpdated WebsocketEventType = "channel_bookmark_updated"
WebsocketEventChannelBookmarkDeleted WebsocketEventType = "channel_bookmark_deleted"
WebsocketEventChannelBookmarkSorted WebsocketEventType = "channel_bookmark_sorted"
WebsocketPresenceIndicator WebsocketEventType = "presence"
WebsocketPostedNotifyAck WebsocketEventType = "posted_notify_ack"
WebsocketScheduledPostCreated WebsocketEventType = "scheduled_post_created"
WebsocketScheduledPostUpdated WebsocketEventType = "scheduled_post_updated"
WebsocketScheduledPostDeleted WebsocketEventType = "scheduled_post_deleted"
WebsocketEventCPAFieldCreated WebsocketEventType = "custom_profile_attributes_field_created"
WebsocketEventCPAFieldUpdated WebsocketEventType = "custom_profile_attributes_field_updated"
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated"
WebsocketEventRecapUpdated WebsocketEventType = "recap_updated"
WebsocketEventPostTranslationUpdated WebsocketEventType = "post_translation_updated"
WebsocketEventPostRevealed WebsocketEventType = "post_revealed"
WebsocketEventPostBurned WebsocketEventType = "post_burned"
WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed"
WebsocketEventFileDownloadRejected WebsocketEventType = "file_download_rejected"
WebsocketEventShowToast WebsocketEventType = "show_toast"
WebSocketMsgTypeResponse = "response"
WebSocketMsgTypeEvent = "event"
)
type ActiveQueueItem struct {
Type string `json:"type"` // websocket event or websocket response
Buf json.RawMessage `json:"buf"`
}
type WSQueues struct {
ActiveQ []ActiveQueueItem `json:"active_queue"` // websocketEvent|websocketResponse
DeadQ []json.RawMessage `json:"dead_queue"` // websocketEvent
ReuseCount int `json:"reuse_count"`
}
type WebSocketMessage interface {
ToJSON() ([]byte, error)
IsValid() bool
EventType() WebsocketEventType
}
type WebsocketBroadcast struct {
OmitUsers map[string]bool `json:"omit_users"` // broadcast is omitted for users listed here
UserId string `json:"user_id"` // broadcast only occurs for this user
ChannelId string `json:"channel_id"` // broadcast only occurs for users in this channel
TeamId string `json:"team_id"` // broadcast only occurs for users in this team
ConnectionId string `json:"connection_id"` // broadcast only occurs for this connection
OmitConnectionId string `json:"omit_connection_id"` // broadcast is omitted for this connection
ContainsSanitizedData bool `json:"contains_sanitized_data,omitempty"` // broadcast only occurs for non-sysadmins
ContainsSensitiveData bool `json:"contains_sensitive_data,omitempty"` // broadcast only occurs for sysadmins
// ReliableClusterSend indicates whether or not the message should
// be sent through the cluster using the reliable, TCP backed channel.
ReliableClusterSend bool `json:"-"`
// BroadcastHooks is a slice of hooks IDs used to process events before sending them on individual connections. The
// IDs should be understood by the WebSocket code.
//
// This field should never be sent to the client.
BroadcastHooks []string `json:"broadcast_hooks,omitempty"`
// BroadcastHookArgs is a slice of named arguments for each hook invocation. The index of each entry corresponds to
// the index of a hook ID in BroadcastHooks
//
// This field should never be sent to the client.
BroadcastHookArgs []map[string]any `json:"broadcast_hook_args,omitempty"`
}
func (wb *WebsocketBroadcast) copy() *WebsocketBroadcast {
if wb == nil {
return nil
}
var c WebsocketBroadcast
if wb.OmitUsers != nil {
c.OmitUsers = make(map[string]bool, len(wb.OmitUsers))
maps.Copy(c.OmitUsers, wb.OmitUsers)
}
c.UserId = wb.UserId
c.ChannelId = wb.ChannelId
c.TeamId = wb.TeamId
c.OmitConnectionId = wb.OmitConnectionId
c.ContainsSanitizedData = wb.ContainsSanitizedData
c.ContainsSensitiveData = wb.ContainsSensitiveData
c.BroadcastHooks = wb.BroadcastHooks
c.BroadcastHookArgs = wb.BroadcastHookArgs
return &c
}
func (wb *WebsocketBroadcast) AddHook(hookID string, hookArgs map[string]any) {
wb.BroadcastHooks = append(wb.BroadcastHooks, hookID)
wb.BroadcastHookArgs = append(wb.BroadcastHookArgs, hookArgs)
}
type precomputedWebSocketEventJSON struct {
Event json.RawMessage
Data json.RawMessage
Broadcast json.RawMessage
}
func (p *precomputedWebSocketEventJSON) copy() *precomputedWebSocketEventJSON {
if p == nil {
return nil
}
var c precomputedWebSocketEventJSON
if p.Event != nil {
c.Event = make([]byte, len(p.Event))
copy(c.Event, p.Event)
}
if p.Data != nil {
c.Data = make([]byte, len(p.Data))
copy(c.Data, p.Data)
}
if p.Broadcast != nil {
c.Broadcast = make([]byte, len(p.Broadcast))
copy(c.Broadcast, p.Broadcast)
}
return &c
}
// webSocketEventJSON mirrors WebSocketEvent to make some of its unexported fields serializable
type webSocketEventJSON struct {
Event WebsocketEventType `json:"event"`
Data map[string]any `json:"data"`
Broadcast *WebsocketBroadcast `json:"broadcast"`
Sequence int64 `json:"seq"`
}
type WebSocketEvent struct {
event WebsocketEventType
data map[string]any
broadcast *WebsocketBroadcast
sequence int64
precomputedJSON *precomputedWebSocketEventJSON
rejected bool
}
// PrecomputeJSON precomputes and stores the serialized JSON for all fields other than Sequence.
// This makes ToJSON much more efficient when sending the same event to multiple connections.
func (ev *WebSocketEvent) PrecomputeJSON() *WebSocketEvent {
evCopy := ev.Copy()
event, _ := json.Marshal(evCopy.event)
data, _ := json.Marshal(evCopy.data)
broadcast, _ := json.Marshal(evCopy.broadcast)
evCopy.precomputedJSON = &precomputedWebSocketEventJSON{
Event: json.RawMessage(event),
Data: json.RawMessage(data),
Broadcast: json.RawMessage(broadcast),
}
return evCopy
}
func (ev *WebSocketEvent) RemovePrecomputedJSON() *WebSocketEvent {
evCopy := ev.DeepCopy()
evCopy.precomputedJSON = nil
return evCopy
}
// WithoutBroadcastHooks gets the broadcast hook information from a WebSocketEvent and returns the event without that.
// If the event has broadcast hooks, a copy of the event is returned. Otherwise, the original event is returned. This
// is intended to be called before the event is sent to the client.
func (ev *WebSocketEvent) WithoutBroadcastHooks() (*WebSocketEvent, []string, []map[string]any) {
hooks := ev.broadcast.BroadcastHooks
hookArgs := ev.broadcast.BroadcastHookArgs
if len(hooks) == 0 && len(hookArgs) == 0 {
return ev, hooks, hookArgs
}
evCopy := ev.Copy()
evCopy.broadcast = ev.broadcast.copy()
evCopy.broadcast.BroadcastHooks = nil
evCopy.broadcast.BroadcastHookArgs = nil
return evCopy, hooks, hookArgs
}
func (ev *WebSocketEvent) Add(key string, value any) {
ev.data[key] = value
}
func NewWebSocketEvent(event WebsocketEventType, teamId, channelId, userId string, omitUsers map[string]bool, omitConnectionId string) *WebSocketEvent {
return &WebSocketEvent{
event: event,
data: make(map[string]any),
broadcast: &WebsocketBroadcast{
TeamId: teamId,
ChannelId: channelId,
UserId: userId,
OmitUsers: omitUsers,
OmitConnectionId: omitConnectionId},
}
}
func (ev *WebSocketEvent) Copy() *WebSocketEvent {
evCopy := &WebSocketEvent{
event: ev.event,
data: ev.data,
broadcast: ev.broadcast,
sequence: ev.sequence,
precomputedJSON: ev.precomputedJSON,
}
return evCopy
}
func (ev *WebSocketEvent) DeepCopy() *WebSocketEvent {
evCopy := &WebSocketEvent{
event: ev.event,
data: maps.Clone(ev.data),
broadcast: ev.broadcast.copy(),
sequence: ev.sequence,
precomputedJSON: ev.precomputedJSON.copy(),
}
return evCopy
}
func (ev *WebSocketEvent) GetData() map[string]any {
return ev.data
}
func (ev *WebSocketEvent) GetBroadcast() *WebsocketBroadcast {
return ev.broadcast
}
func (ev *WebSocketEvent) GetSequence() int64 {
return ev.sequence
}
func (ev *WebSocketEvent) SetEvent(event WebsocketEventType) *WebSocketEvent {
evCopy := ev.Copy()
evCopy.event = event
return evCopy
}
func (ev *WebSocketEvent) SetData(data map[string]any) *WebSocketEvent {
evCopy := ev.Copy()
evCopy.data = data
return evCopy
}
func (ev *WebSocketEvent) SetBroadcast(broadcast *WebsocketBroadcast) *WebSocketEvent {
evCopy := ev.Copy()
evCopy.broadcast = broadcast
return evCopy
}
func (ev *WebSocketEvent) SetSequence(seq int64) *WebSocketEvent {
evCopy := ev.Copy()
evCopy.sequence = seq
return evCopy
}
func (ev *WebSocketEvent) IsValid() bool {
return ev.event != ""
}
func (ev *WebSocketEvent) EventType() WebsocketEventType {
return ev.event
}
func (ev *WebSocketEvent) ToJSON() ([]byte, error) {
if ev.precomputedJSON != nil {
return ev.precomputedJSONBuf(), nil
}
return json.Marshal(webSocketEventJSON{
ev.event,
ev.data,
ev.broadcast,
ev.sequence,
})
}
// Encode encodes the event to the given encoder.
func (ev *WebSocketEvent) Encode(enc *json.Encoder, buf io.Writer) error {
if ev.precomputedJSON != nil {
_, err := buf.Write(ev.precomputedJSONBuf())
return err
}
return enc.Encode(webSocketEventJSON{
ev.event,
ev.data,
ev.broadcast,
ev.sequence,
})
}
// We write optimal code here sacrificing readability for
// performance.
func (ev *WebSocketEvent) precomputedJSONBuf() []byte {
return []byte(`{"event": ` +
string(ev.precomputedJSON.Event) +
`, "data": ` +
string(ev.precomputedJSON.Data) +
`, "broadcast": ` +
string(ev.precomputedJSON.Broadcast) +
`, "seq": ` +
strconv.Itoa(int(ev.sequence)) +
`}`)
}
func WebSocketEventFromJSON(data io.Reader) (*WebSocketEvent, error) {
var ev WebSocketEvent
var o webSocketEventJSON
if err := json.NewDecoder(data).Decode(&o); err != nil {
return nil, err
}
ev.event = o.Event
if u, ok := o.Data["user"]; ok {
// We need to convert to and from JSON again
// because the user is in the form of a map[string]any.
buf, err := json.Marshal(u)
if err != nil {
return nil, err
}
var user User
if err = json.Unmarshal(buf, &user); err != nil {
return nil, err
}
o.Data["user"] = &user
}
ev.data = o.Data
ev.broadcast = o.Broadcast
ev.sequence = o.Sequence
return &ev, nil
}
// WebSocketResponse represents a response received through the WebSocket
// for a request made to the server. This is available through the ResponseChannel
// channel in WebSocketClient.
type WebSocketResponse struct {
Status string `json:"status"` // The status of the response. For example: OK, FAIL.
SeqReply int64 `json:"seq_reply,omitempty"` // A counter which is incremented for every response sent.
Data map[string]any `json:"data,omitempty"` // The data contained in the response.
Error *AppError `json:"error,omitempty"` // A field that is set if any error has occurred.
}
func (m *WebSocketResponse) Add(key string, value any) {
m.Data[key] = value
}
func NewWebSocketResponse(status string, seqReply int64, data map[string]any) *WebSocketResponse {
return &WebSocketResponse{Status: status, SeqReply: seqReply, Data: data}
}
func NewWebSocketError(seqReply int64, err *AppError) *WebSocketResponse {
return &WebSocketResponse{Status: StatusFail, SeqReply: seqReply, Error: err}
}
func (m *WebSocketResponse) IsValid() bool {
return m.Status != ""
}
func (m *WebSocketResponse) EventType() WebsocketEventType {
return WebsocketEventResponse
}
func (m *WebSocketResponse) ToJSON() ([]byte, error) {
return json.Marshal(m)
}
func WebSocketResponseFromJSON(data io.Reader) (*WebSocketResponse, error) {
var o *WebSocketResponse
return o, json.NewDecoder(data).Decode(&o)
}
func (ev *WebSocketEvent) Reject() {
ev.rejected = true
}
func (ev *WebSocketEvent) IsRejected() bool {
return ev.rejected
}