Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

49
server/boards/ws/adapter.go Обычный файл
Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:generate mockgen -copyright_file=../../copyright.txt -destination=mocks/mockstore.go -package mocks . Store
package ws
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
const (
websocketActionAuth = "AUTH"
websocketActionSubscribeTeam = "SUBSCRIBE_TEAM"
websocketActionUnsubscribeTeam = "UNSUBSCRIBE_TEAM"
websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS"
websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS"
websocketActionUpdateBoard = "UPDATE_BOARD"
websocketActionUpdateMember = "UPDATE_MEMBER"
websocketActionDeleteMember = "DELETE_MEMBER"
websocketActionUpdateBlock = "UPDATE_BLOCK"
websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG"
websocketActionUpdateCategory = "UPDATE_CATEGORY"
websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY"
websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION"
websocketActionUpdateCardLimitTimestamp = "UPDATE_CARD_LIMIT_TIMESTAMP"
websocketActionReorderCategories = "REORDER_CATEGORIES"
websocketActionReorderCategoryBoards = "REORDER_CATEGORY_BOARDS"
)
type Store interface {
GetBlock(blockID string) (*model.Block, error)
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
}
type Adapter interface {
BroadcastBlockChange(teamID string, block *model.Block)
BroadcastBlockDelete(teamID, blockID, boardID string)
BroadcastBoardChange(teamID string, board *model.Board)
BroadcastBoardDelete(teamID, boardID string)
BroadcastMemberChange(teamID, boardID string, member *model.BoardMember)
BroadcastMemberDelete(teamID, boardID, userID string)
BroadcastConfigChange(clientConfig model.ClientConfig)
BroadcastCategoryChange(category model.Category)
BroadcastCategoryBoardChange(teamID, userID string, blockCategory []*model.BoardCategoryWebsocketData)
BroadcastCardLimitTimestampChange(cardLimitTimestamp int64)
BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
BroadcastCategoryReorder(teamID, userID string, categoryOrder []string)
BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string)
}

77
server/boards/ws/common.go Обычный файл
Просмотреть файл

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
// UpdateCategoryMessage is sent on block updates.
type UpdateCategoryMessage struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Category *model.Category `json:"category,omitempty"`
BoardCategories []*model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"`
}
// UpdateBlockMsg is sent on block updates.
type UpdateBlockMsg struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Block *model.Block `json:"block"`
}
// UpdateBoardMsg is sent on block updates.
type UpdateBoardMsg struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Board *model.Board `json:"board"`
}
// UpdateMemberMsg is sent on membership updates.
type UpdateMemberMsg struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Member *model.BoardMember `json:"member"`
}
// UpdateSubscription is sent on subscription updates.
type UpdateSubscription struct {
Action string `json:"action"`
Subscription *model.Subscription `json:"subscription"`
}
// UpdateClientConfig is sent on block updates.
type UpdateClientConfig struct {
Action string `json:"action"`
ClientConfig model.ClientConfig `json:"clientconfig"`
}
// UpdateClientConfig is sent on block updates.
type UpdateCardLimitTimestamp struct {
Action string `json:"action"`
Timestamp int64 `json:"timestamp"`
}
// WebsocketCommand is an incoming command from the client.
type WebsocketCommand struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Token string `json:"token"`
ReadToken string `json:"readToken"`
BlockIDs []string `json:"blockIds"`
}
type CategoryReorderMessage struct {
Action string `json:"action"`
CategoryOrder []string `json:"categoryOrder"`
TeamID string `json:"teamId"`
}
type CategoryBoardReorderMessage struct {
Action string `json:"action"`
CategoryID string `json:"CategoryId"`
BoardOrder []string `json:"BoardOrder"`
TeamID string `json:"teamId"`
}

64
server/boards/ws/helpers_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"testing"
authMocks "github.com/mattermost/mattermost-server/v6/server/boards/auth/mocks"
wsMocks "github.com/mattermost/mattermost-server/v6/server/boards/ws/mocks"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/golang/mock/gomock"
)
type TestHelper struct {
api *wsMocks.MockAPI
auth *authMocks.MockAuthInterface
store *wsMocks.MockStore
ctrl *gomock.Controller
pa *PluginAdapter
}
func SetupTestHelper(t *testing.T) *TestHelper {
ctrl := gomock.NewController(t)
mockAPI := wsMocks.NewMockAPI(ctrl)
mockAuth := authMocks.NewMockAuthInterface(ctrl)
mockStore := wsMocks.NewMockStore(ctrl)
mockAPI.EXPECT().LogDebug(gomock.Any(), gomock.Any()).AnyTimes()
mockAPI.EXPECT().LogInfo(gomock.Any(), gomock.Any()).AnyTimes()
mockAPI.EXPECT().LogError(gomock.Any(), gomock.Any()).AnyTimes()
mockAPI.EXPECT().LogWarn(gomock.Any(), gomock.Any()).AnyTimes()
return &TestHelper{
api: mockAPI,
auth: mockAuth,
store: mockStore,
ctrl: ctrl,
pa: NewPluginAdapter(mockAPI, mockAuth, mockStore, mlog.CreateConsoleTestLogger(true, mlog.LvlDebug)),
}
}
func (th *TestHelper) ReceiveWebSocketMessage(webConnID, userID, action string, data map[string]interface{}) {
req := &mm_model.WebSocketRequest{Action: websocketMessagePrefix + action, Data: data}
th.pa.WebSocketMessageHasBeenPosted(webConnID, userID, req)
}
func (th *TestHelper) SubscribeWebConnToTeam(webConnID, userID, teamID string) {
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID, teamID).
Return(true)
msgData := map[string]interface{}{"teamId": teamID}
th.ReceiveWebSocketMessage(webConnID, userID, websocketActionSubscribeTeam, msgData)
}
func (th *TestHelper) UnsubscribeWebConnFromTeam(webConnID, userID, teamID string) {
msgData := map[string]interface{}{"teamId": teamID}
th.ReceiveWebSocketMessage(webConnID, userID, websocketActionUnsubscribeTeam, msgData)
}

2659
server/boards/ws/mocks/mockpluginapi.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

68
server/boards/ws/mocks/mockstore.go Обычный файл
Просмотреть файл

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost-server/v6/server/boards/ws (interfaces: Store)
// Package mocks is a generated GoMock package.
package mocks
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost-server/v6/server/boards/model"
)
// MockStore is a mock of Store interface.
type MockStore struct {
ctrl *gomock.Controller
recorder *MockStoreMockRecorder
}
// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder struct {
mock *MockStore
}
// NewMockStore creates a new mock instance.
func NewMockStore(ctrl *gomock.Controller) *MockStore {
mock := &MockStore{ctrl: ctrl}
mock.recorder = &MockStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
return m.recorder
}
// GetBlock mocks base method.
func (m *MockStore) GetBlock(arg0 string) (*model.Block, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBlock", arg0)
ret0, _ := ret[0].(*model.Block)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetBlock indicates an expected call of GetBlock.
func (mr *MockStoreMockRecorder) GetBlock(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlock", reflect.TypeOf((*MockStore)(nil).GetBlock), arg0)
}
// GetMembersForBoard mocks base method.
func (m *MockStore) GetMembersForBoard(arg0 string) ([]*model.BoardMember, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMembersForBoard", arg0)
ret0, _ := ret[0].([]*model.BoardMember)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMembersForBoard indicates an expected call of GetMembersForBoard.
func (mr *MockStoreMockRecorder) GetMembersForBoard(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMembersForBoard", reflect.TypeOf((*MockStore)(nil).GetMembersForBoard), arg0)
}

678
server/boards/ws/plugin_adapter.go Обычный файл
Просмотреть файл

@@ -0,0 +1,678 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:generate mockgen -copyright_file=../../copyright.txt -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
package ws
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const websocketMessagePrefix = "custom_boards_"
var errMissingTeamInCommand = fmt.Errorf("command doesn't contain teamId")
type PluginAdapterInterface interface {
Adapter
OnWebSocketConnect(webConnID, userID string)
OnWebSocketDisconnect(webConnID, userID string)
WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest)
BroadcastConfigChange(clientConfig model.ClientConfig)
BroadcastBlockChange(teamID string, block *model.Block)
BroadcastBlockDelete(teamID, blockID, parentID string)
BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
BroadcastCardLimitTimestampChange(cardLimitTimestamp int64)
HandleClusterEvent(ev mm_model.PluginClusterEvent)
}
type PluginAdapter struct {
api servicesAPI
auth auth.AuthInterface
staleThreshold time.Duration
store Store
logger mlog.LoggerIFace
listenersMU sync.RWMutex
listeners map[string]*PluginAdapterClient
listenersByUserID map[string][]*PluginAdapterClient
subscriptionsMU sync.RWMutex
listenersByTeam map[string][]*PluginAdapterClient
listenersByBlock map[string][]*PluginAdapterClient
}
// servicesAPI is the interface required by the PluginAdapter to interact with
// the mattermost-server.
type servicesAPI interface {
PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast)
PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error
}
func NewPluginAdapter(api servicesAPI, auth auth.AuthInterface, store Store, logger mlog.LoggerIFace) *PluginAdapter {
return &PluginAdapter{
api: api,
auth: auth,
store: store,
staleThreshold: 5 * time.Minute,
logger: logger,
listeners: make(map[string]*PluginAdapterClient),
listenersByUserID: make(map[string][]*PluginAdapterClient),
listenersByTeam: make(map[string][]*PluginAdapterClient),
listenersByBlock: make(map[string][]*PluginAdapterClient),
listenersMU: sync.RWMutex{},
subscriptionsMU: sync.RWMutex{},
}
}
func (pa *PluginAdapter) GetListenerByWebConnID(webConnID string) (pac *PluginAdapterClient, ok bool) {
pa.listenersMU.RLock()
defer pa.listenersMU.RUnlock()
pac, ok = pa.listeners[webConnID]
return
}
func (pa *PluginAdapter) GetListenersByUserID(userID string) []*PluginAdapterClient {
pa.listenersMU.RLock()
defer pa.listenersMU.RUnlock()
return pa.listenersByUserID[userID]
}
func (pa *PluginAdapter) GetListenersByTeam(teamID string) []*PluginAdapterClient {
pa.subscriptionsMU.RLock()
defer pa.subscriptionsMU.RUnlock()
return pa.listenersByTeam[teamID]
}
func (pa *PluginAdapter) GetListenersByBlock(blockID string) []*PluginAdapterClient {
pa.subscriptionsMU.RLock()
defer pa.subscriptionsMU.RUnlock()
return pa.listenersByBlock[blockID]
}
func (pa *PluginAdapter) addListener(pac *PluginAdapterClient) {
pa.listenersMU.Lock()
defer pa.listenersMU.Unlock()
pa.listeners[pac.webConnID] = pac
pa.listenersByUserID[pac.userID] = append(pa.listenersByUserID[pac.userID], pac)
}
func (pa *PluginAdapter) removeListener(pac *PluginAdapterClient) {
pa.listenersMU.Lock()
defer pa.listenersMU.Unlock()
// team subscriptions
for _, team := range pac.teams {
pa.removeListenerFromTeam(pac, team)
}
// block subscriptions
for _, block := range pac.blocks {
pa.removeListenerFromBlock(pac, block)
}
// user ID list
newUserListeners := []*PluginAdapterClient{}
for _, listener := range pa.listenersByUserID[pac.userID] {
if listener.webConnID != pac.webConnID {
newUserListeners = append(newUserListeners, listener)
}
}
pa.listenersByUserID[pac.userID] = newUserListeners
delete(pa.listeners, pac.webConnID)
}
func (pa *PluginAdapter) removeExpiredForUserID(userID string) {
for _, pac := range pa.GetListenersByUserID(userID) {
if !pac.isActive() && pac.hasExpired(pa.staleThreshold) {
pa.removeListener(pac)
}
}
}
func (pa *PluginAdapter) removeListenerFromTeam(pac *PluginAdapterClient, teamID string) {
newTeamListeners := []*PluginAdapterClient{}
for _, listener := range pa.GetListenersByTeam(teamID) {
if listener.webConnID != pac.webConnID {
newTeamListeners = append(newTeamListeners, listener)
}
}
pa.subscriptionsMU.Lock()
pa.listenersByTeam[teamID] = newTeamListeners
pa.subscriptionsMU.Unlock()
pac.unsubscribeFromTeam(teamID)
}
func (pa *PluginAdapter) removeListenerFromBlock(pac *PluginAdapterClient, blockID string) {
newBlockListeners := []*PluginAdapterClient{}
for _, listener := range pa.GetListenersByBlock(blockID) {
if listener.webConnID != pac.webConnID {
newBlockListeners = append(newBlockListeners, listener)
}
}
pa.subscriptionsMU.Lock()
pa.listenersByBlock[blockID] = newBlockListeners
pa.subscriptionsMU.Unlock()
pac.unsubscribeFromBlock(blockID)
}
func (pa *PluginAdapter) subscribeListenerToTeam(pac *PluginAdapterClient, teamID string) {
if pac.isSubscribedToTeam(teamID) {
return
}
pa.subscriptionsMU.Lock()
pa.listenersByTeam[teamID] = append(pa.listenersByTeam[teamID], pac)
pa.subscriptionsMU.Unlock()
pac.subscribeToTeam(teamID)
}
func (pa *PluginAdapter) unsubscribeListenerFromTeam(pac *PluginAdapterClient, teamID string) {
if !pac.isSubscribedToTeam(teamID) {
return
}
pa.removeListenerFromTeam(pac, teamID)
}
func (pa *PluginAdapter) getUserIDsForTeam(teamID string) []string {
userMap := map[string]bool{}
for _, pac := range pa.GetListenersByTeam(teamID) {
if pac.isActive() {
userMap[pac.userID] = true
}
}
userIDs := []string{}
for userID := range userMap {
if pa.auth.DoesUserHaveTeamAccess(userID, teamID) {
userIDs = append(userIDs, userID)
}
}
return userIDs
}
func (pa *PluginAdapter) getUserIDsForTeamAndBoard(teamID, boardID string, ensureUserIDs ...string) []string {
userMap := map[string]bool{}
for _, pac := range pa.GetListenersByTeam(teamID) {
if pac.isActive() {
userMap[pac.userID] = true
}
}
members, err := pa.store.GetMembersForBoard(boardID)
if err != nil {
pa.logger.Error("error getting members for board",
mlog.String("method", "getUserIDsForTeamAndBoard"),
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
)
return nil
}
// the list of users would be the intersection between the ones
// that are connected to the team and the board members that need
// to see the updates
userIDs := []string{}
for _, member := range members {
for userID := range userMap {
if userID == member.UserID && pa.auth.DoesUserHaveTeamAccess(userID, teamID) {
userIDs = append(userIDs, userID)
}
}
}
// if we don't have to make sure that some IDs are included, we
// can return at this point
if len(ensureUserIDs) == 0 {
return userIDs
}
completeUserMap := map[string]bool{}
for _, id := range userIDs {
completeUserMap[id] = true
}
for _, id := range ensureUserIDs {
completeUserMap[id] = true
}
completeUserIDs := []string{}
for id := range completeUserMap {
completeUserIDs = append(completeUserIDs, id)
}
return completeUserIDs
}
//nolint:unused
func (pa *PluginAdapter) unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string) {
for _, blockID := range blockIDs {
if pac.isSubscribedToBlock(blockID) {
pa.removeListenerFromBlock(pac, blockID)
}
}
}
func (pa *PluginAdapter) OnWebSocketConnect(webConnID, userID string) {
if existingPAC, ok := pa.GetListenerByWebConnID(webConnID); ok {
pa.logger.Debug("inactive connection found for webconn, reusing",
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
)
atomic.StoreInt64(&existingPAC.inactiveAt, 0)
return
}
newPAC := &PluginAdapterClient{
inactiveAt: 0,
webConnID: webConnID,
userID: userID,
teams: []string{},
blocks: []string{},
}
pa.addListener(newPAC)
pa.removeExpiredForUserID(userID)
}
func (pa *PluginAdapter) OnWebSocketDisconnect(webConnID, userID string) {
pac, ok := pa.GetListenerByWebConnID(webConnID)
if !ok {
pa.logger.Debug("received a disconnect for an unregistered webconn",
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
)
return
}
atomic.StoreInt64(&pac.inactiveAt, mm_model.GetMillis())
}
func commandFromRequest(req *mm_model.WebSocketRequest) (*WebsocketCommand, error) {
c := &WebsocketCommand{Action: strings.TrimPrefix(req.Action, websocketMessagePrefix)}
if teamID, ok := req.Data["teamId"]; ok {
c.TeamID = teamID.(string)
} else {
return nil, errMissingTeamInCommand
}
if readToken, ok := req.Data["readToken"]; ok {
c.ReadToken = readToken.(string)
}
if blockIDs, ok := req.Data["blockIds"]; ok {
c.BlockIDs = blockIDs.([]string)
}
return c, nil
}
func (pa *PluginAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
pac, ok := pa.GetListenerByWebConnID(webConnID)
if !ok {
pa.logger.Debug("received a message for an unregistered webconn",
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
mlog.String("action", req.Action),
)
return
}
// only process messages using the plugin actions
if !strings.HasPrefix(req.Action, websocketMessagePrefix) {
return
}
command, err := commandFromRequest(req)
if err != nil {
pa.logger.Error("error getting command from request",
mlog.String("action", req.Action),
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
mlog.Err(err),
)
return
}
switch command.Action {
// The block-related commands are not implemented in the adapter
// as there is no such thing as unauthenticated websocket
// connections in plugin mode. Only a debug line is logged
case websocketActionSubscribeBlocks, websocketActionUnsubscribeBlocks:
pa.logger.Debug(`Command not implemented in plugin mode`,
mlog.String("command", command.Action),
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
mlog.String("teamID", command.TeamID),
)
case websocketActionSubscribeTeam:
pa.logger.Debug(`Command not implemented in plugin mode`,
mlog.String("command", command.Action),
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
mlog.String("teamID", command.TeamID),
)
if !pa.auth.DoesUserHaveTeamAccess(userID, command.TeamID) {
return
}
pa.subscribeListenerToTeam(pac, command.TeamID)
case websocketActionUnsubscribeTeam:
pa.logger.Debug(`Command: UNSUBSCRIBE_WORKSPACE`,
mlog.String("webConnID", webConnID),
mlog.String("userID", userID),
mlog.String("teamID", command.TeamID),
)
pa.unsubscribeListenerFromTeam(pac, command.TeamID)
}
}
// sendMessageToAll will send a websocket message to all clients on all nodes.
func (pa *PluginAdapter) sendMessageToAll(event string, payload map[string]interface{}) {
// Empty &mm_model.WebsocketBroadcast will send to all users
pa.api.PublishWebSocketEvent(event, payload, &mm_model.WebsocketBroadcast{})
}
func (pa *PluginAdapter) BroadcastConfigChange(pluginConfig model.ClientConfig) {
pa.sendMessageToAll(websocketActionUpdateConfig, utils.StructToMap(pluginConfig))
}
// sendUserMessageSkipCluster sends the message to specific users.
func (pa *PluginAdapter) sendUserMessageSkipCluster(event string, payload map[string]interface{}, userIDs ...string) {
for _, userID := range userIDs {
pa.api.PublishWebSocketEvent(event, payload, &mm_model.WebsocketBroadcast{UserId: userID})
}
}
// sendTeamMessageSkipCluster sends a message to all the users
// with a websocket client subscribed to a given team.
func (pa *PluginAdapter) sendTeamMessageSkipCluster(event, teamID string, payload map[string]interface{}) {
userIDs := pa.getUserIDsForTeam(teamID)
pa.sendUserMessageSkipCluster(event, payload, userIDs...)
}
// sendTeamMessage sends and propagates a message that is aimed
// for all the users that are subscribed to a given team.
func (pa *PluginAdapter) sendTeamMessage(event, teamID string, payload map[string]interface{}, ensureUserIDs ...string) {
go func() {
clusterMessage := &ClusterMessage{
TeamID: teamID,
Payload: payload,
EnsureUsers: ensureUserIDs,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendTeamMessageSkipCluster(event, teamID, payload)
}
// sendBoardMessageSkipCluster sends a message to all the users
// subscribed to a given team that belong to one of its boards.
func (pa *PluginAdapter) sendBoardMessageSkipCluster(teamID, boardID string, payload map[string]interface{}, ensureUserIDs ...string) {
userIDs := pa.getUserIDsForTeamAndBoard(teamID, boardID, ensureUserIDs...)
pa.sendUserMessageSkipCluster(websocketActionUpdateBoard, payload, userIDs...)
}
// sendBoardMessage sends and propagates a message that is aimed for
// all the users that are subscribed to the board's team and are
// members of it too.
func (pa *PluginAdapter) sendBoardMessage(teamID, boardID string, payload map[string]interface{}, ensureUserIDs ...string) {
go func() {
clusterMessage := &ClusterMessage{
TeamID: teamID,
BoardID: boardID,
Payload: payload,
EnsureUsers: ensureUserIDs,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendBoardMessageSkipCluster(teamID, boardID, payload, ensureUserIDs...)
}
func (pa *PluginAdapter) BroadcastBlockChange(teamID string, block *model.Block) {
pa.logger.Trace("BroadcastingBlockChange",
mlog.String("teamID", teamID),
mlog.String("boardID", block.BoardID),
mlog.String("blockID", block.ID),
)
message := UpdateBlockMsg{
Action: websocketActionUpdateBlock,
TeamID: teamID,
Block: block,
}
pa.sendBoardMessage(teamID, block.BoardID, utils.StructToMap(message))
}
func (pa *PluginAdapter) BroadcastCategoryChange(category model.Category) {
pa.logger.Debug("BroadcastCategoryChange",
mlog.String("userID", category.UserID),
mlog.String("teamID", category.TeamID),
mlog.String("categoryID", category.ID),
)
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategory,
TeamID: category.TeamID,
Category: &category,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: category.UserID,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendUserMessageSkipCluster(websocketActionUpdateCategory, payload, category.UserID)
}
func (pa *PluginAdapter) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) {
pa.logger.Debug("BroadcastCategoryReorder",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
)
message := CategoryReorderMessage{
Action: websocketActionReorderCategories,
CategoryOrder: categoryOrder,
TeamID: teamID,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: userID,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendUserMessageSkipCluster(message.Action, payload, userID)
}
func (pa *PluginAdapter) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) {
pa.logger.Debug("BroadcastCategoryBoardsReorder",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
mlog.String("categoryID", categoryID),
)
message := CategoryBoardReorderMessage{
Action: websocketActionReorderCategoryBoards,
CategoryID: categoryID,
BoardOrder: boardsOrder,
TeamID: teamID,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: userID,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendUserMessageSkipCluster(message.Action, payload, userID)
}
func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) {
pa.logger.Debug(
"BroadcastCategoryBoardChange",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
mlog.Int("numEntries", len(boardCategories)),
)
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategoryBoard,
TeamID: teamID,
BoardCategories: boardCategories,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: userID,
}
pa.sendMessageToCluster(clusterMessage)
}()
pa.sendUserMessageSkipCluster(websocketActionUpdateCategoryBoard, utils.StructToMap(message), userID)
}
func (pa *PluginAdapter) BroadcastBlockDelete(teamID, blockID, boardID string) {
now := utils.GetMillis()
block := &model.Block{}
block.ID = blockID
block.BoardID = boardID
block.UpdateAt = now
block.DeleteAt = now
pa.BroadcastBlockChange(teamID, block)
}
func (pa *PluginAdapter) BroadcastBoardChange(teamID string, board *model.Board) {
pa.logger.Debug("BroadcastingBoardChange",
mlog.String("teamID", teamID),
mlog.String("boardID", board.ID),
)
message := UpdateBoardMsg{
Action: websocketActionUpdateBoard,
TeamID: teamID,
Board: board,
}
pa.sendBoardMessage(teamID, board.ID, utils.StructToMap(message))
}
func (pa *PluginAdapter) BroadcastBoardDelete(teamID, boardID string) {
now := utils.GetMillis()
board := &model.Board{}
board.ID = boardID
board.TeamID = teamID
board.UpdateAt = now
board.DeleteAt = now
pa.BroadcastBoardChange(teamID, board)
}
func (pa *PluginAdapter) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) {
pa.logger.Debug("BroadcastingMemberChange",
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
mlog.String("userID", member.UserID),
)
message := UpdateMemberMsg{
Action: websocketActionUpdateMember,
TeamID: teamID,
Member: member,
}
pa.sendBoardMessage(teamID, boardID, utils.StructToMap(message), member.UserID)
}
func (pa *PluginAdapter) BroadcastMemberDelete(teamID, boardID, userID string) {
pa.logger.Debug("BroadcastingMemberDelete",
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
mlog.String("userID", userID),
)
message := UpdateMemberMsg{
Action: websocketActionDeleteMember,
TeamID: teamID,
Member: &model.BoardMember{UserID: userID, BoardID: boardID},
}
// when fetching the members of the board that should receive the
// member deletion message, the deleted member will not be one of
// them, so we need to ensure they receive the message
pa.sendBoardMessage(teamID, boardID, utils.StructToMap(message), userID)
}
func (pa *PluginAdapter) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) {
pa.logger.Debug("BroadcastingSubscriptionChange",
mlog.String("TeamID", teamID),
mlog.String("blockID", subscription.BlockID),
mlog.String("subscriberID", subscription.SubscriberID),
)
message := UpdateSubscription{
Action: websocketActionUpdateSubscription,
Subscription: subscription,
}
pa.sendTeamMessage(websocketActionUpdateSubscription, teamID, utils.StructToMap(message))
}
func (pa *PluginAdapter) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) {
pa.logger.Debug("BroadcastCardLimitTimestampChange",
mlog.Int64("cardLimitTimestamp", cardLimitTimestamp),
)
message := UpdateCardLimitTimestamp{
Action: websocketActionUpdateCardLimitTimestamp,
Timestamp: cardLimitTimestamp,
}
pa.sendMessageToAll(websocketActionUpdateCardLimitTimestamp, utils.StructToMap(message))
}

89
server/boards/ws/plugin_adapter_client.go Обычный файл
Просмотреть файл

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"sync"
"sync/atomic"
"time"
mm_model "github.com/mattermost/mattermost-server/v6/model"
)
type PluginAdapterClient struct {
inactiveAt int64
webConnID string
userID string
teams []string
blocks []string
mu sync.RWMutex
}
func (pac *PluginAdapterClient) isActive() bool {
return atomic.LoadInt64(&pac.inactiveAt) == 0
}
func (pac *PluginAdapterClient) hasExpired(threshold time.Duration) bool {
return !mm_model.GetTimeForMillis(atomic.LoadInt64(&pac.inactiveAt)).Add(threshold).After(time.Now())
}
func (pac *PluginAdapterClient) subscribeToTeam(teamID string) {
pac.mu.Lock()
defer pac.mu.Unlock()
pac.teams = append(pac.teams, teamID)
}
func (pac *PluginAdapterClient) unsubscribeFromTeam(teamID string) {
pac.mu.Lock()
defer pac.mu.Unlock()
newClientTeams := []string{}
for _, id := range pac.teams {
if id != teamID {
newClientTeams = append(newClientTeams, id)
}
}
pac.teams = newClientTeams
}
func (pac *PluginAdapterClient) unsubscribeFromBlock(blockID string) {
pac.mu.Lock()
defer pac.mu.Unlock()
newClientBlocks := []string{}
for _, id := range pac.blocks {
if id != blockID {
newClientBlocks = append(newClientBlocks, id)
}
}
pac.blocks = newClientBlocks
}
func (pac *PluginAdapterClient) isSubscribedToTeam(teamID string) bool {
pac.mu.RLock()
defer pac.mu.RUnlock()
for _, id := range pac.teams {
if id == teamID {
return true
}
}
return false
}
//nolint:unused
func (pac *PluginAdapterClient) isSubscribedToBlock(blockID string) bool {
pac.mu.RLock()
defer pac.mu.RUnlock()
for _, id := range pac.blocks {
if id == blockID {
return true
}
}
return false
}

83
server/boards/ws/plugin_adapter_cluster.go Обычный файл
Просмотреть файл

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"encoding/json"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
type ClusterMessage struct {
TeamID string
BoardID string
UserID string
Payload map[string]interface{}
EnsureUsers []string
}
func (pa *PluginAdapter) sendMessageToCluster(clusterMessage *ClusterMessage) {
const id = "websocket_message"
b, err := json.Marshal(clusterMessage)
if err != nil {
pa.logger.Error("couldn't get JSON bytes from cluster message",
mlog.String("id", id),
mlog.Err(err),
)
return
}
event := mm_model.PluginClusterEvent{Id: id, Data: b}
opts := mm_model.PluginClusterEventSendOptions{
SendType: mm_model.PluginClusterEventSendTypeReliable,
}
if err := pa.api.PublishPluginClusterEvent(event, opts); err != nil {
pa.logger.Error("error publishing cluster event",
mlog.String("id", id),
mlog.Err(err),
)
}
}
func (pa *PluginAdapter) HandleClusterEvent(ev mm_model.PluginClusterEvent) {
pa.logger.Debug("received cluster event", mlog.String("id", ev.Id))
var clusterMessage ClusterMessage
if err := json.Unmarshal(ev.Data, &clusterMessage); err != nil {
pa.logger.Error("cannot unmarshal cluster message data",
mlog.String("id", ev.Id),
mlog.Err(err),
)
return
}
if clusterMessage.BoardID != "" {
pa.sendBoardMessageSkipCluster(clusterMessage.TeamID, clusterMessage.BoardID, clusterMessage.Payload, clusterMessage.EnsureUsers...)
return
}
var action string
if actionRaw, ok := clusterMessage.Payload["action"]; ok {
if s, ok := actionRaw.(string); ok {
action = s
}
}
if action == "" {
// no action was specified in the event; assume block change and warn.
pa.logger.Warn("cannot determine action from cluster message data",
mlog.String("id", ev.Id),
mlog.Map("payload", clusterMessage.Payload),
)
return
}
if clusterMessage.UserID != "" {
pa.sendUserMessageSkipCluster(action, clusterMessage.Payload, clusterMessage.UserID)
return
}
pa.sendTeamMessageSkipCluster(action, clusterMessage.TeamID, clusterMessage.Payload)
}

556
server/boards/ws/plugin_adapter_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,556 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"sync"
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/require"
)
func TestPluginAdapterTeamSubscription(t *testing.T) {
th := SetupTestHelper(t)
webConnID := mm_model.NewId()
userID := mm_model.NewId()
teamID := mm_model.NewId()
var pac *PluginAdapterClient
t.Run("Should correctly add a connection", func(t *testing.T) {
require.Empty(t, th.pa.listeners)
require.Empty(t, th.pa.listenersByTeam)
th.pa.OnWebSocketConnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
var ok bool
pac, ok = th.pa.listeners[webConnID]
require.True(t, ok)
require.NotNil(t, pac)
require.Equal(t, userID, pac.userID)
require.Empty(t, th.pa.listenersByTeam)
})
t.Run("Should correctly subscribe to a team", func(t *testing.T) {
require.False(t, pac.isSubscribedToTeam(teamID))
th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID)
require.Len(t, th.pa.listenersByTeam[teamID], 1)
require.Contains(t, th.pa.listenersByTeam[teamID], pac)
require.Len(t, pac.teams, 1)
require.Contains(t, pac.teams, teamID)
require.True(t, pac.isSubscribedToTeam(teamID))
})
t.Run("Subscribing again to a subscribed team would have no effect", func(t *testing.T) {
require.True(t, pac.isSubscribedToTeam(teamID))
th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID)
require.Len(t, th.pa.listenersByTeam[teamID], 1)
require.Contains(t, th.pa.listenersByTeam[teamID], pac)
require.Len(t, pac.teams, 1)
require.Contains(t, pac.teams, teamID)
require.True(t, pac.isSubscribedToTeam(teamID))
})
t.Run("Should correctly unsubscribe to a team", func(t *testing.T) {
require.True(t, pac.isSubscribedToTeam(teamID))
th.UnsubscribeWebConnFromTeam(pac.webConnID, pac.userID, teamID)
require.Empty(t, th.pa.listenersByTeam[teamID])
require.Empty(t, pac.teams)
require.False(t, pac.isSubscribedToTeam(teamID))
})
t.Run("Unsubscribing again to an unsubscribed team would have no effect", func(t *testing.T) {
require.False(t, pac.isSubscribedToTeam(teamID))
th.UnsubscribeWebConnFromTeam(pac.webConnID, pac.userID, teamID)
require.Empty(t, th.pa.listenersByTeam[teamID])
require.Empty(t, pac.teams)
require.False(t, pac.isSubscribedToTeam(teamID))
})
t.Run("Should correctly be marked as inactive if disconnected", func(t *testing.T) {
require.Len(t, th.pa.listeners, 1)
require.True(t, th.pa.listeners[webConnID].isActive())
th.pa.OnWebSocketDisconnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.False(t, th.pa.listeners[webConnID].isActive())
})
t.Run("Should be marked back as active if reconnect", func(t *testing.T) {
require.Len(t, th.pa.listeners, 1)
require.False(t, th.pa.listeners[webConnID].isActive())
th.pa.OnWebSocketConnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.True(t, th.pa.listeners[webConnID].isActive())
})
}
func TestPluginAdapterClientReconnect(t *testing.T) {
th := SetupTestHelper(t)
webConnID := mm_model.NewId()
userID := mm_model.NewId()
teamID := mm_model.NewId()
var pac *PluginAdapterClient
t.Run("A user should be able to reconnect within the accepted threshold and keep their subscriptions", func(t *testing.T) {
// create the connection
require.Len(t, th.pa.listeners, 0)
require.Len(t, th.pa.listenersByUserID[userID], 0)
th.pa.OnWebSocketConnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.Len(t, th.pa.listenersByUserID[userID], 1)
var ok bool
pac, ok = th.pa.listeners[webConnID]
require.True(t, ok)
require.NotNil(t, pac)
th.SubscribeWebConnToTeam(pac.webConnID, pac.userID, teamID)
require.True(t, pac.isSubscribedToTeam(teamID))
// disconnect
th.pa.OnWebSocketDisconnect(webConnID, userID)
require.False(t, pac.isActive())
require.Len(t, th.pa.listeners, 1)
require.Len(t, th.pa.listenersByUserID[userID], 1)
// reconnect right away. The connection should still be subscribed
th.pa.OnWebSocketConnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.Len(t, th.pa.listenersByUserID[userID], 1)
require.True(t, pac.isActive())
require.True(t, pac.isSubscribedToTeam(teamID))
})
t.Run("Should remove old inactive connection when user connects with a different ID", func(t *testing.T) {
// we set the stale threshold to zero so inactive connections always get deleted
oldStaleThreshold := th.pa.staleThreshold
th.pa.staleThreshold = 0
defer func() { th.pa.staleThreshold = oldStaleThreshold }()
th.pa.OnWebSocketDisconnect(webConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.Len(t, th.pa.listenersByUserID[userID], 1)
require.Equal(t, webConnID, th.pa.listenersByUserID[userID][0].webConnID)
newWebConnID := mm_model.NewId()
th.pa.OnWebSocketConnect(newWebConnID, userID)
require.Len(t, th.pa.listeners, 1)
require.Len(t, th.pa.listenersByUserID[userID], 1)
require.Contains(t, th.pa.listeners, newWebConnID)
require.NotContains(t, th.pa.listeners, webConnID)
require.Equal(t, newWebConnID, th.pa.listenersByUserID[userID][0].webConnID)
// if the same ID connects again, it should have no subscriptions
th.pa.OnWebSocketConnect(webConnID, userID)
require.Len(t, th.pa.listeners, 2)
require.Len(t, th.pa.listenersByUserID[userID], 2)
reconnectedPAC, ok := th.pa.listeners[webConnID]
require.True(t, ok)
require.False(t, reconnectedPAC.isSubscribedToTeam(teamID))
})
t.Run("Should not remove active connections when user connects with a different ID", func(t *testing.T) {
// we set the stale threshold to zero so inactive connections always get deleted
oldStaleThreshold := th.pa.staleThreshold
th.pa.staleThreshold = 0
defer func() { th.pa.staleThreshold = oldStaleThreshold }()
// currently we have two listeners for userID, both active
require.Len(t, th.pa.listeners, 2)
// a new user connects
th.pa.OnWebSocketConnect(mm_model.NewId(), userID)
// and we should have three connections, all of them active
require.Len(t, th.pa.listeners, 3)
for _, listener := range th.pa.listeners {
require.True(t, listener.isActive())
}
})
}
func TestGetUserIDsForTeam(t *testing.T) {
th := SetupTestHelper(t)
// we have two teams
teamID1 := mm_model.NewId()
teamID2 := mm_model.NewId()
// user 1 has two connections
userID1 := mm_model.NewId()
webConnID1 := mm_model.NewId()
webConnID2 := mm_model.NewId()
// user 2 has one connection
userID2 := mm_model.NewId()
webConnID3 := mm_model.NewId()
wg := new(sync.WaitGroup)
wg.Add(3)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID1, userID1)
th.SubscribeWebConnToTeam(webConnID1, userID1, teamID1)
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID2, userID1)
th.SubscribeWebConnToTeam(webConnID2, userID1, teamID2)
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID3, userID2)
th.SubscribeWebConnToTeam(webConnID3, userID2, teamID2)
wg.Done()
}(wg)
wg.Wait()
t.Run("should find that only user1 is connected to team 1", func(t *testing.T) {
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID1).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID1)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
t.Run("should find that both users are connected to team 2", func(t *testing.T) {
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID2)
require.ElementsMatch(t, []string{userID1, userID2}, userIDs)
})
t.Run("should ignore user1 if webConn 2 inactive when getting team 2 user ids", func(t *testing.T) {
th.pa.OnWebSocketDisconnect(webConnID2, userID1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID2)
require.ElementsMatch(t, []string{userID2}, userIDs)
})
t.Run("should still find user 1 in team 1 after the webConn 2 disconnection", func(t *testing.T) {
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID1).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID1)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
t.Run("should find again both users if the webConn 2 comes back", func(t *testing.T) {
th.pa.OnWebSocketConnect(webConnID2, userID1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID2)
require.ElementsMatch(t, []string{userID1, userID2}, userIDs)
})
t.Run("should only find user 1 if user 2 has an active connection but is not a team member anymore", func(t *testing.T) {
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
// userID2 does not have team access
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(false).
Times(1)
userIDs := th.pa.getUserIDsForTeam(teamID2)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
}
func TestGetUserIDsForTeamAndBoard(t *testing.T) {
th := SetupTestHelper(t)
// we have two teams
teamID1 := mm_model.NewId()
boardID1 := mm_model.NewId()
teamID2 := mm_model.NewId()
boardID2 := mm_model.NewId()
// user 1 has two connections
userID1 := mm_model.NewId()
webConnID1 := mm_model.NewId()
webConnID2 := mm_model.NewId()
// user 2 has one connection
userID2 := mm_model.NewId()
webConnID3 := mm_model.NewId()
wg := new(sync.WaitGroup)
wg.Add(3)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID1, userID1)
th.SubscribeWebConnToTeam(webConnID1, userID1, teamID1)
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID2, userID1)
th.SubscribeWebConnToTeam(webConnID2, userID1, teamID2)
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.pa.OnWebSocketConnect(webConnID3, userID2)
th.SubscribeWebConnToTeam(webConnID3, userID2, teamID2)
wg.Done()
}(wg)
wg.Wait()
t.Run("should find that only user1 is connected to team 1 and board 1", func(t *testing.T) {
mockedMembers := []*model.BoardMember{{UserID: userID1}}
th.store.EXPECT().
GetMembersForBoard(boardID1).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID1).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID1, boardID1)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
t.Run("should find that both users are connected to team 2 and board 2", func(t *testing.T) {
mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}}
th.store.EXPECT().
GetMembersForBoard(boardID2).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2)
require.ElementsMatch(t, []string{userID1, userID2}, userIDs)
})
t.Run("should find that only one user is connected to team 2 and board 2 if there is only one membership with both connected", func(t *testing.T) {
mockedMembers := []*model.BoardMember{{UserID: userID1}}
th.store.EXPECT().
GetMembersForBoard(boardID2).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
t.Run("should find only one if the other is inactive", func(t *testing.T) {
th.pa.OnWebSocketDisconnect(webConnID3, userID2)
defer th.pa.OnWebSocketConnect(webConnID3, userID2)
mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}}
th.store.EXPECT().
GetMembersForBoard(boardID2).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
t.Run("should include a user that is not present if it's ensured", func(t *testing.T) {
userID3 := mm_model.NewId()
mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}}
th.store.EXPECT().
GetMembersForBoard(boardID2).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(true).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2, userID3)
require.ElementsMatch(t, []string{userID1, userID2, userID3}, userIDs)
})
t.Run("should not include a user that, although present, has no team access anymore", func(t *testing.T) {
mockedMembers := []*model.BoardMember{{UserID: userID1}, {UserID: userID2}}
th.store.EXPECT().
GetMembersForBoard(boardID2).
Return(mockedMembers, nil).
Times(1)
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID1, teamID2).
Return(true).
Times(1)
// userID2 has no team access
th.auth.EXPECT().
DoesUserHaveTeamAccess(userID2, teamID2).
Return(false).
Times(1)
userIDs := th.pa.getUserIDsForTeamAndBoard(teamID2, boardID2)
require.ElementsMatch(t, []string{userID1}, userIDs)
})
}
func TestParallelSubscriptionsOnMultipleConnections(t *testing.T) {
th := SetupTestHelper(t)
teamID1 := mm_model.NewId()
teamID2 := mm_model.NewId()
teamID3 := mm_model.NewId()
teamID4 := mm_model.NewId()
userID := mm_model.NewId()
webConnID1 := mm_model.NewId()
webConnID2 := mm_model.NewId()
th.pa.OnWebSocketConnect(webConnID1, userID)
pac1, ok := th.pa.GetListenerByWebConnID(webConnID1)
require.True(t, ok)
th.pa.OnWebSocketConnect(webConnID2, userID)
pac2, ok := th.pa.GetListenerByWebConnID(webConnID2)
require.True(t, ok)
wg := new(sync.WaitGroup)
wg.Add(4)
go func(wg *sync.WaitGroup) {
th.SubscribeWebConnToTeam(webConnID1, userID, teamID1)
require.True(t, pac1.isSubscribedToTeam(teamID1))
th.SubscribeWebConnToTeam(webConnID2, userID, teamID1)
require.True(t, pac2.isSubscribedToTeam(teamID1))
th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID1)
require.False(t, pac1.isSubscribedToTeam(teamID1))
th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID1)
require.False(t, pac2.isSubscribedToTeam(teamID1))
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.SubscribeWebConnToTeam(webConnID1, userID, teamID2)
require.True(t, pac1.isSubscribedToTeam(teamID2))
th.SubscribeWebConnToTeam(webConnID2, userID, teamID2)
require.True(t, pac2.isSubscribedToTeam(teamID2))
th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID2)
require.False(t, pac1.isSubscribedToTeam(teamID2))
th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID2)
require.False(t, pac2.isSubscribedToTeam(teamID2))
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.SubscribeWebConnToTeam(webConnID1, userID, teamID3)
require.True(t, pac1.isSubscribedToTeam(teamID3))
th.SubscribeWebConnToTeam(webConnID2, userID, teamID3)
require.True(t, pac2.isSubscribedToTeam(teamID3))
th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID3)
require.False(t, pac1.isSubscribedToTeam(teamID3))
th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID3)
require.False(t, pac2.isSubscribedToTeam(teamID3))
wg.Done()
}(wg)
go func(wg *sync.WaitGroup) {
th.SubscribeWebConnToTeam(webConnID1, userID, teamID4)
require.True(t, pac1.isSubscribedToTeam(teamID4))
th.SubscribeWebConnToTeam(webConnID2, userID, teamID4)
require.True(t, pac2.isSubscribedToTeam(teamID4))
th.UnsubscribeWebConnFromTeam(webConnID1, userID, teamID4)
require.False(t, pac1.isSubscribedToTeam(teamID4))
th.UnsubscribeWebConnFromTeam(webConnID2, userID, teamID4)
require.False(t, pac2.isSubscribedToTeam(teamID4))
wg.Done()
}(wg)
wg.Wait()
}

810
server/boards/ws/server.go Обычный файл
Просмотреть файл

@@ -0,0 +1,810 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"encoding/json"
"net/http"
"sync"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (wss *websocketSession) WriteJSON(v interface{}) error {
wss.mu.Lock()
defer wss.mu.Unlock()
err := wss.conn.WriteJSON(v)
return err
}
func (wss *websocketSession) isSubscribedToTeam(teamID string) bool {
for _, id := range wss.teams {
if id == teamID {
return true
}
}
return false
}
func (wss *websocketSession) isSubscribedToBlock(blockID string) bool {
for _, id := range wss.blocks {
if id == blockID {
return true
}
}
return false
}
// Server is a WebSocket server.
type Server struct {
upgrader websocket.Upgrader
listeners map[*websocketSession]bool
listenersByTeam map[string][]*websocketSession
listenersByBlock map[string][]*websocketSession
mu sync.RWMutex
auth *auth.Auth
singleUserToken string
isMattermostAuth bool
logger mlog.LoggerIFace
store Store
}
type websocketSession struct {
conn *websocket.Conn
userID string
mu sync.Mutex
teams []string
blocks []string
}
func (wss *websocketSession) isAuthenticated() bool {
return wss.userID != ""
}
// NewServer creates a new Server.
func NewServer(auth *auth.Auth, singleUserToken string, isMattermostAuth bool, logger mlog.LoggerIFace, store Store) *Server {
return &Server{
listeners: make(map[*websocketSession]bool),
listenersByTeam: make(map[string][]*websocketSession),
listenersByBlock: make(map[string][]*websocketSession),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
},
auth: auth,
singleUserToken: singleUserToken,
isMattermostAuth: isMattermostAuth,
logger: logger,
store: store,
}
}
// RegisterRoutes registers routes.
func (ws *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/ws", ws.handleWebSocket)
}
func (ws *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// Upgrade initial GET request to a websocket
client, err := ws.upgrader.Upgrade(w, r, nil)
if err != nil {
ws.logger.Error("ERROR upgrading to websocket", mlog.Err(err))
return
}
// create an empty session with websocket client
wsSession := &websocketSession{
conn: client,
userID: "",
mu: sync.Mutex{},
teams: []string{},
blocks: []string{},
}
if ws.isMattermostAuth {
wsSession.userID = r.Header.Get("Mattermost-User-Id")
}
ws.addListener(wsSession)
// Make sure we close the connection when the function returns
defer func() {
ws.logger.Debug("DISCONNECT WebSocket", mlog.Stringer("client", wsSession.conn.RemoteAddr()))
// Remove session from listeners
ws.removeListener(wsSession)
wsSession.conn.Close()
}()
// Simple message handling loop
for {
_, p, err := wsSession.conn.ReadMessage()
if err != nil {
ws.logger.Error("ERROR WebSocket",
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
mlog.Err(err),
)
ws.removeListener(wsSession)
break
}
var command WebsocketCommand
err = json.Unmarshal(p, &command)
if err != nil {
// handle this error
ws.logger.Error(`ERROR webSocket parsing command`, mlog.String("json", string(p)))
continue
}
if command.Action == websocketActionAuth {
ws.logger.Debug(`Command: AUTH`, mlog.Stringer("client", wsSession.conn.RemoteAddr()))
ws.authenticateListener(wsSession, command.Token)
continue
}
// if the client wants to subscribe to a set of blocks and it
// is sending a read token, we don't need to check for
// authentication
if command.Action == websocketActionSubscribeBlocks {
ws.logger.Debug(`Command: SUBSCRIBE_BLOCKS`,
mlog.String("teamID", command.TeamID),
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
)
if !ws.isCommandReadTokenValid(command) {
ws.logger.Error(`Rejected invalid read token`,
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
mlog.String("action", command.Action),
mlog.String("readToken", command.ReadToken),
)
continue
}
ws.subscribeListenerToBlocks(wsSession, command.BlockIDs)
continue
}
if command.Action == websocketActionUnsubscribeBlocks {
ws.logger.Debug(`Command: UNSUBSCRIBE_BLOCKS`,
mlog.String("teamID", command.TeamID),
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
)
if !ws.isCommandReadTokenValid(command) {
ws.logger.Error(`Rejected invalid read token`,
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
mlog.String("action", command.Action),
mlog.String("readToken", command.ReadToken),
)
continue
}
ws.unsubscribeListenerFromBlocks(wsSession, command.BlockIDs)
continue
}
// if the command is not authenticated at this point, it will
// not be processed
if !wsSession.isAuthenticated() {
ws.logger.Error(`Rejected unauthenticated message`,
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
mlog.String("action", command.Action),
)
continue
}
switch command.Action {
case websocketActionSubscribeTeam:
ws.logger.Debug(`Command: SUBSCRIBE_TEAM`,
mlog.String("teamID", command.TeamID),
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
)
// if single user mode, check that the userID is valid and
// assume that the user has permission if so
if ws.singleUserToken != "" {
if wsSession.userID != model.SingleUser {
continue
}
// if not in single user mode validate that the session
// has permissions to the team
} else {
ws.logger.Debug("Not single user mode")
if !ws.auth.DoesUserHaveTeamAccess(wsSession.userID, command.TeamID) {
ws.logger.Error("WS user doesn't have team access", mlog.String("teamID", command.TeamID), mlog.String("userID", wsSession.userID))
continue
}
}
ws.subscribeListenerToTeam(wsSession, command.TeamID)
case websocketActionUnsubscribeTeam:
ws.logger.Debug(`Command: UNSUBSCRIBE_TEAM`,
mlog.String("teamID", command.TeamID),
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
)
ws.unsubscribeListenerFromTeam(wsSession, command.TeamID)
default:
ws.logger.Error(`ERROR webSocket command, invalid action`, mlog.String("action", command.Action))
}
}
}
// isCommandReadTokenValid ensures that a command contains a read
// token and a set of block ids that said token is valid for.
func (ws *Server) isCommandReadTokenValid(command WebsocketCommand) bool {
if command.TeamID == "" {
return false
}
boardID := ""
// all the blocks must be part of the same board
for _, blockID := range command.BlockIDs {
block, err := ws.store.GetBlock(blockID)
if err != nil {
return false
}
if boardID == "" {
boardID = block.BoardID
continue
}
if boardID != block.BoardID {
return false
}
}
// the read token must be valid for the board
isValid, err := ws.auth.IsValidReadToken(boardID, command.ReadToken)
if err != nil {
ws.logger.Error(`ERROR when checking token validity`,
mlog.String("teamID", command.TeamID),
mlog.Err(err),
)
return false
}
return isValid
}
// addListener adds a listener to the websocket server. The listener
// should not receive any update from the server until it subscribes
// itself to some entity changes. Adding a listener to the server
// doesn't mean that it's authenticated in any way.
func (ws *Server) addListener(listener *websocketSession) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.listeners[listener] = true
}
// removeListener removes a listener and all its subscriptions, if
// any, from the websockets server.
func (ws *Server) removeListener(listener *websocketSession) {
ws.mu.Lock()
defer ws.mu.Unlock()
// remove the listener from its subscriptions, if any
// team subscriptions
for _, team := range listener.teams {
ws.removeListenerFromTeam(listener, team)
}
// block subscriptions
for _, block := range listener.blocks {
ws.removeListenerFromBlock(listener, block)
}
delete(ws.listeners, listener)
}
// subscribeListenerToTeam safely modifies the listener and the
// server to subscribe the listener to a given team updates.
func (ws *Server) subscribeListenerToTeam(listener *websocketSession, teamID string) {
if listener.isSubscribedToTeam(teamID) {
return
}
ws.mu.Lock()
defer ws.mu.Unlock()
ws.listenersByTeam[teamID] = append(ws.listenersByTeam[teamID], listener)
listener.teams = append(listener.teams, teamID)
}
// unsubscribeListenerFromTeam safely modifies the listener and
// the server data structures to remove the link between the listener
// and a given team ID.
func (ws *Server) unsubscribeListenerFromTeam(listener *websocketSession, teamID string) {
if !listener.isSubscribedToTeam(teamID) {
return
}
ws.mu.Lock()
defer ws.mu.Unlock()
ws.removeListenerFromTeam(listener, teamID)
}
// subscribeListenerToBlocks safely modifies the listener and the
// server to subscribe the listener to a given set of block updates.
func (ws *Server) subscribeListenerToBlocks(listener *websocketSession, blockIDs []string) {
ws.mu.Lock()
defer ws.mu.Unlock()
for _, blockID := range blockIDs {
if listener.isSubscribedToBlock(blockID) {
continue
}
ws.listenersByBlock[blockID] = append(ws.listenersByBlock[blockID], listener)
listener.blocks = append(listener.blocks, blockID)
}
}
// unsubscribeListenerFromBlocks safely modifies the listener and the
// server data structures to remove the link between the listener and
// a given set of block IDs.
func (ws *Server) unsubscribeListenerFromBlocks(listener *websocketSession, blockIDs []string) {
ws.mu.Lock()
defer ws.mu.Unlock()
for _, blockID := range blockIDs {
if listener.isSubscribedToBlock(blockID) {
ws.removeListenerFromBlock(listener, blockID)
}
}
}
// removeListenerFromTeam removes the listener from both its own
// block subscribed list and the server listeners by team map.
func (ws *Server) removeListenerFromTeam(listener *websocketSession, teamID string) {
// we remove the listener from the team index
newTeamListeners := []*websocketSession{}
for _, l := range ws.listenersByTeam[teamID] {
if l != listener {
newTeamListeners = append(newTeamListeners, l)
}
}
ws.listenersByTeam[teamID] = newTeamListeners
// we remove the team from the listener subscription list
newListenerTeams := []string{}
for _, id := range listener.teams {
if id != teamID {
newListenerTeams = append(newListenerTeams, id)
}
}
listener.teams = newListenerTeams
}
// removeListenerFromBlock removes the listener from both its own
// block subscribed list and the server listeners by block map.
func (ws *Server) removeListenerFromBlock(listener *websocketSession, blockID string) {
// we remove the listener from the block index
newBlockListeners := []*websocketSession{}
for _, l := range ws.listenersByBlock[blockID] {
if l != listener {
newBlockListeners = append(newBlockListeners, l)
}
}
ws.listenersByBlock[blockID] = newBlockListeners
// we remove the block from the listener subscription list
newListenerBlocks := []string{}
for _, id := range listener.blocks {
if id != blockID {
newListenerBlocks = append(newListenerBlocks, id)
}
}
listener.blocks = newListenerBlocks
}
func (ws *Server) getUserIDForToken(token string) string {
if ws.singleUserToken != "" {
if token == ws.singleUserToken {
return model.SingleUser
}
return ""
}
session, err := ws.auth.GetSession(token)
if session == nil || err != nil {
return ""
}
return session.UserID
}
func (ws *Server) authenticateListener(wsSession *websocketSession, token string) {
ws.logger.Debug("authenticateListener",
mlog.String("token", token),
mlog.String("wsSession.userID", wsSession.userID),
)
if wsSession.isAuthenticated() {
// Do not allow multiple auth calls (for security)
ws.logger.Debug(
"authenticateListener: Ignoring already authenticated session",
mlog.String("userID", wsSession.userID),
mlog.Stringer("client", wsSession.conn.RemoteAddr()),
)
return
}
// Authenticate session
userID := ws.getUserIDForToken(token)
if userID == "" {
wsSession.conn.Close()
return
}
// Authenticated
wsSession.userID = userID
ws.logger.Debug("authenticateListener: Authenticated", mlog.String("userID", userID), mlog.Stringer("client", wsSession.conn.RemoteAddr()))
}
// getListenersForBlock returns the listeners subscribed to a
// block changes.
func (ws *Server) getListenersForBlock(blockID string) []*websocketSession {
return ws.listenersByBlock[blockID]
}
// getListenersForTeam returns the listeners subscribed to a
// team changes.
func (ws *Server) getListenersForTeam(teamID string) []*websocketSession {
return ws.listenersByTeam[teamID]
}
// getListenersForTeamAndBoard returns the listeners subscribed to a
// team changes and members of a given board.
func (ws *Server) getListenersForTeamAndBoard(teamID, boardID string, ensureUsers ...string) []*websocketSession {
members, err := ws.store.GetMembersForBoard(boardID)
if err != nil {
ws.logger.Error("error getting members for board",
mlog.String("method", "getListenersForTeamAndBoard"),
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
)
return nil
}
memberMap := map[string]bool{}
for _, member := range members {
memberMap[member.UserID] = true
}
for _, id := range ensureUsers {
memberMap[id] = true
}
memberIDs := []string{}
for id := range memberMap {
memberIDs = append(memberIDs, id)
}
listeners := []*websocketSession{}
for _, memberID := range memberIDs {
for _, listener := range ws.listenersByTeam[teamID] {
if listener.userID == memberID {
listeners = append(listeners, listener)
}
}
}
return listeners
}
// BroadcastBlockDelete broadcasts delete messages to clients.
func (ws *Server) BroadcastBlockDelete(teamID, blockID, boardID string) {
now := utils.GetMillis()
block := &model.Block{}
block.ID = blockID
block.BoardID = boardID
block.UpdateAt = now
block.DeleteAt = now
ws.BroadcastBlockChange(teamID, block)
}
// BroadcastBlockChange broadcasts update messages to clients.
func (ws *Server) BroadcastBlockChange(teamID string, block *model.Block) {
blockIDsToNotify := []string{block.ID, block.ParentID}
message := UpdateBlockMsg{
Action: websocketActionUpdateBlock,
TeamID: teamID,
Block: block,
}
listeners := ws.getListenersForTeamAndBoard(teamID, block.BoardID)
ws.logger.Trace("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("boardID", block.BoardID),
)
for _, blockID := range blockIDsToNotify {
listeners = append(listeners, ws.getListenersForBlock(blockID)...)
ws.logger.Trace("listener(s) for blockID",
mlog.Int("listener_count", len(listeners)),
mlog.String("blockID", blockID),
)
}
for _, listener := range listeners {
ws.logger.Debug("Broadcast block change",
mlog.String("teamID", teamID),
mlog.String("blockID", block.ID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryChange(category model.Category) {
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategory,
TeamID: category.TeamID,
Category: &category,
}
listeners := ws.getListenersForTeam(category.TeamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", category.TeamID),
mlog.String("categoryID", category.ID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast block change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", category.TeamID),
mlog.String("categoryID", category.ID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category change error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) {
message := CategoryReorderMessage{
Action: websocketActionReorderCategories,
CategoryOrder: categoryOrder,
TeamID: teamID,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast category order change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category order change error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardOrder []string) {
message := CategoryBoardReorderMessage{
Action: websocketActionReorderCategoryBoards,
CategoryID: categoryID,
BoardOrder: boardOrder,
TeamID: teamID,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast board category order change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category order change error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) {
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategoryBoard,
TeamID: teamID,
BoardCategories: boardCategories,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Int("numEntries", len(boardCategories)),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast block change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Int("numEntries", len(boardCategories)),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category change error", mlog.Err(err))
listener.conn.Close()
}
}
}
// BroadcastConfigChange broadcasts update messages to clients.
func (ws *Server) BroadcastConfigChange(clientConfig model.ClientConfig) {
message := UpdateClientConfig{
Action: websocketActionUpdateConfig,
ClientConfig: clientConfig,
}
listeners := ws.listeners
ws.logger.Debug("broadcasting config change to listener(s)",
mlog.Int("listener_count", len(listeners)),
)
for listener := range listeners {
ws.logger.Debug("Broadcast Config change",
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastBoardChange(teamID string, board *model.Board) {
message := UpdateBoardMsg{
Action: websocketActionUpdateBoard,
TeamID: teamID,
Board: board,
}
listeners := ws.getListenersForTeamAndBoard(teamID, board.ID)
ws.logger.Trace("listener(s) for teamID and boardID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("boardID", board.ID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast board change",
mlog.String("teamID", teamID),
mlog.String("boardID", board.ID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastBoardDelete(teamID, boardID string) {
now := utils.GetMillis()
board := &model.Board{}
board.ID = boardID
board.TeamID = teamID
board.UpdateAt = now
board.DeleteAt = now
ws.BroadcastBoardChange(teamID, board)
}
func (ws *Server) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) {
message := UpdateMemberMsg{
Action: websocketActionUpdateMember,
TeamID: teamID,
Member: member,
}
listeners := ws.getListenersForTeamAndBoard(teamID, boardID)
ws.logger.Trace("listener(s) for teamID and boardID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast member change",
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastMemberDelete(teamID, boardID, userID string) {
message := UpdateMemberMsg{
Action: websocketActionDeleteMember,
TeamID: teamID,
Member: &model.BoardMember{UserID: userID, BoardID: boardID},
}
// when fetching the members of the board that should receive the
// member deletion message, the deleted member will not be one of
// them, so we need to ensure they receive the message
listeners := ws.getListenersForTeamAndBoard(teamID, boardID, userID)
ws.logger.Trace("listener(s) for teamID and boardID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast member removal",
mlog.String("teamID", teamID),
mlog.String("boardID", boardID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) {
// not implemented for standalone server.
}
func (ws *Server) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) {
// not implemented for standalone server.
}

244
server/boards/ws/server_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package ws
import (
"sync"
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
)
func TestTeamSubscription(t *testing.T) {
server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil)
session := &websocketSession{
conn: &websocket.Conn{},
mu: sync.Mutex{},
teams: []string{},
blocks: []string{},
}
teamID := "fake-team-id"
t.Run("Should correctly add a session", func(t *testing.T) {
server.addListener(session)
require.Len(t, server.listeners, 1)
require.Empty(t, server.listenersByTeam)
require.Empty(t, session.teams)
})
t.Run("Should correctly subscribe to a team", func(t *testing.T) {
require.False(t, session.isSubscribedToTeam(teamID))
server.subscribeListenerToTeam(session, teamID)
require.Len(t, server.listenersByTeam[teamID], 1)
require.Contains(t, server.listenersByTeam[teamID], session)
require.Len(t, session.teams, 1)
require.Contains(t, session.teams, teamID)
require.True(t, session.isSubscribedToTeam(teamID))
})
t.Run("Subscribing again to a subscribed team would have no effect", func(t *testing.T) {
require.True(t, session.isSubscribedToTeam(teamID))
server.subscribeListenerToTeam(session, teamID)
require.Len(t, server.listenersByTeam[teamID], 1)
require.Contains(t, server.listenersByTeam[teamID], session)
require.Len(t, session.teams, 1)
require.Contains(t, session.teams, teamID)
require.True(t, session.isSubscribedToTeam(teamID))
})
t.Run("Should correctly unsubscribe to a team", func(t *testing.T) {
require.True(t, session.isSubscribedToTeam(teamID))
server.unsubscribeListenerFromTeam(session, teamID)
require.Empty(t, server.listenersByTeam[teamID])
require.Empty(t, session.teams)
require.False(t, session.isSubscribedToTeam(teamID))
})
t.Run("Unsubscribing again to an unsubscribed team would have no effect", func(t *testing.T) {
require.False(t, session.isSubscribedToTeam(teamID))
server.unsubscribeListenerFromTeam(session, teamID)
require.Empty(t, server.listenersByTeam[teamID])
require.Empty(t, session.teams)
require.False(t, session.isSubscribedToTeam(teamID))
})
t.Run("Should correctly be removed from the server", func(t *testing.T) {
server.removeListener(session)
require.Empty(t, server.listeners)
})
t.Run("If subscribed to teams and removed, should be removed from the teams subscription list", func(t *testing.T) {
teamID2 := "other-fake-team-id"
server.addListener(session)
server.subscribeListenerToTeam(session, teamID)
server.subscribeListenerToTeam(session, teamID2)
require.Len(t, server.listeners, 1)
require.Contains(t, server.listenersByTeam[teamID], session)
require.Contains(t, server.listenersByTeam[teamID2], session)
server.removeListener(session)
require.Empty(t, server.listeners)
require.Empty(t, server.listenersByTeam[teamID])
require.Empty(t, server.listenersByTeam[teamID2])
})
}
func TestBlocksSubscription(t *testing.T) {
server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil)
session := &websocketSession{
conn: &websocket.Conn{},
mu: sync.Mutex{},
teams: []string{},
blocks: []string{},
}
blockID1 := "block1"
blockID2 := "block2"
blockID3 := "block3"
blockIDs := []string{blockID1, blockID2, blockID3}
t.Run("Should correctly add a session", func(t *testing.T) {
server.addListener(session)
require.Len(t, server.listeners, 1)
require.Empty(t, server.listenersByTeam)
require.Empty(t, session.teams)
})
t.Run("Should correctly subscribe to a set of blocks", func(t *testing.T) {
require.False(t, session.isSubscribedToBlock(blockID1))
require.False(t, session.isSubscribedToBlock(blockID2))
require.False(t, session.isSubscribedToBlock(blockID3))
server.subscribeListenerToBlocks(session, blockIDs)
require.Len(t, server.listenersByBlock[blockID1], 1)
require.Contains(t, server.listenersByBlock[blockID1], session)
require.Len(t, server.listenersByBlock[blockID2], 1)
require.Contains(t, server.listenersByBlock[blockID2], session)
require.Len(t, server.listenersByBlock[blockID3], 1)
require.Contains(t, server.listenersByBlock[blockID3], session)
require.Len(t, session.blocks, 3)
require.ElementsMatch(t, blockIDs, session.blocks)
require.True(t, session.isSubscribedToBlock(blockID1))
require.True(t, session.isSubscribedToBlock(blockID2))
require.True(t, session.isSubscribedToBlock(blockID3))
t.Run("Subscribing again to a subscribed block would have no effect", func(t *testing.T) {
require.True(t, session.isSubscribedToBlock(blockID1))
require.True(t, session.isSubscribedToBlock(blockID2))
require.True(t, session.isSubscribedToBlock(blockID3))
server.subscribeListenerToBlocks(session, blockIDs)
require.Len(t, server.listenersByBlock[blockID1], 1)
require.Contains(t, server.listenersByBlock[blockID1], session)
require.Len(t, server.listenersByBlock[blockID2], 1)
require.Contains(t, server.listenersByBlock[blockID2], session)
require.Len(t, server.listenersByBlock[blockID3], 1)
require.Contains(t, server.listenersByBlock[blockID3], session)
require.Len(t, session.blocks, 3)
require.ElementsMatch(t, blockIDs, session.blocks)
require.True(t, session.isSubscribedToBlock(blockID1))
require.True(t, session.isSubscribedToBlock(blockID2))
require.True(t, session.isSubscribedToBlock(blockID3))
})
})
t.Run("Should correctly unsubscribe to a set of blocks", func(t *testing.T) {
require.True(t, session.isSubscribedToBlock(blockID1))
require.True(t, session.isSubscribedToBlock(blockID2))
require.True(t, session.isSubscribedToBlock(blockID3))
server.unsubscribeListenerFromBlocks(session, blockIDs)
require.Empty(t, server.listenersByBlock[blockID1])
require.Empty(t, server.listenersByBlock[blockID2])
require.Empty(t, server.listenersByBlock[blockID3])
require.Empty(t, session.blocks)
require.False(t, session.isSubscribedToBlock(blockID1))
require.False(t, session.isSubscribedToBlock(blockID2))
require.False(t, session.isSubscribedToBlock(blockID3))
})
t.Run("Unsubscribing again to an unsubscribed block would have no effect", func(t *testing.T) {
require.False(t, session.isSubscribedToBlock(blockID1))
server.unsubscribeListenerFromBlocks(session, []string{blockID1})
require.Empty(t, server.listenersByBlock[blockID1])
require.Empty(t, session.blocks)
require.False(t, session.isSubscribedToBlock(blockID1))
})
t.Run("Should correctly be removed from the server", func(t *testing.T) {
server.removeListener(session)
require.Empty(t, server.listeners)
})
t.Run("If subscribed to blocks and removed, should be removed from the blocks subscription list", func(t *testing.T) {
server.addListener(session)
server.subscribeListenerToBlocks(session, blockIDs)
require.Len(t, server.listeners, 1)
require.Len(t, server.listenersByBlock[blockID1], 1)
require.Contains(t, server.listenersByBlock[blockID1], session)
require.Len(t, server.listenersByBlock[blockID2], 1)
require.Contains(t, server.listenersByBlock[blockID2], session)
require.Len(t, server.listenersByBlock[blockID3], 1)
require.Contains(t, server.listenersByBlock[blockID3], session)
require.Len(t, session.blocks, 3)
require.ElementsMatch(t, blockIDs, session.blocks)
server.removeListener(session)
require.Empty(t, server.listeners)
require.Empty(t, server.listenersByBlock[blockID1])
require.Empty(t, server.listenersByBlock[blockID2])
require.Empty(t, server.listenersByBlock[blockID3])
})
}
func TestGetUserIDForTokenInSingleUserMode(t *testing.T) {
singleUserToken := "single-user-token"
server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{}, nil)
server.singleUserToken = singleUserToken
t.Run("Should return nothing if the token is empty", func(t *testing.T) {
require.Empty(t, server.getUserIDForToken(""))
})
t.Run("Should return nothing if the token is invalid", func(t *testing.T) {
require.Empty(t, server.getUserIDForToken("invalid-token"))
})
t.Run("Should return the single user ID if the token is correct", func(t *testing.T) {
require.Equal(t, model.SingleUser, server.getUserIDForToken(singleUserToken))
})
}