1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-11-24 08:22:29 +02:00

Adds limits implementation to the server (#3213)

* Adds limits implementation to the server

* Add test for deleted boards on active card count
This commit is contained in:
Miguel de la Cruz 2022-06-15 12:17:44 +02:00 committed by GitHub
parent d34bf0391b
commit fa6de94070
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 2035 additions and 118 deletions

View File

@ -17,6 +17,7 @@ require (
github.com/Masterminds/squirrel v1.5.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect

View File

@ -210,6 +210,7 @@ github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO
github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/blevesearch/bleve/v2 v2.3.2/go.mod h1:96+xE5pZUOsr3Y4vHzV1cBC837xZCpwLlX0hrrxnvIg=
github.com/blevesearch/bleve_index_api v1.0.1/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4=

View File

@ -18,6 +18,7 @@ import (
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
"github.com/mattermost/focalboard/server/services/store/sqlstore"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/ws"
pluginapi "github.com/mattermost/mattermost-plugin-api"
@ -152,6 +153,8 @@ func (p *Plugin) OnActivate() error {
WSAdapter: p.wsPluginAdapter,
NotifyBackends: notifyBackends,
PermissionsService: permissionsService,
PluginAPI: p.API,
Client: client,
}
server, err := server.New(params)
@ -162,6 +165,19 @@ func (p *Plugin) OnActivate() error {
backendParams.appAPI.init(db, server.App())
if utils.IsCloudLicense(p.API.GetLicense()) {
limits, err := p.API.GetCloudLimits()
if err != nil {
fmt.Println("ERROR FETCHING CLOUD LIMITS WHEN STARTING THE PLUGIN", err)
return err
}
if err := server.App().SetCloudLimits(limits); err != nil {
fmt.Println("ERROR SETTING CLOUD LIMITS WHEN STARTING THE PLUGIN", err)
return err
}
}
p.server = server
return server.Start()
}
@ -510,3 +526,9 @@ func isBoardsLink(link string) bool {
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
}
func (p *Plugin) OnCloudLimitsUpdated(limits *mmModel.ProductLimits) {
if err := p.server.App().SetCloudLimits(limits); err != nil {
fmt.Println("Error setting the cloud limits for Boards", err)
}
}

View File

@ -39,7 +39,10 @@ import wsClient, {
ACTION_UPDATE_BLOCK,
ACTION_UPDATE_CLIENT_CONFIG,
ACTION_UPDATE_SUBSCRIPTION,
ACTION_UPDATE_CATEGORY, ACTION_UPDATE_BOARD_CATEGORY, ACTION_UPDATE_BOARD,
ACTION_UPDATE_CARD_LIMIT_TIMESTAMP,
ACTION_UPDATE_CATEGORY,
ACTION_UPDATE_BOARD_CATEGORY,
ACTION_UPDATE_BOARD,
} from './../../../webapp/src/wsclient'
import manifest from './manifest'
@ -275,6 +278,7 @@ export default class Plugin {
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {

View File

@ -153,6 +153,9 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv2.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST")
// limits
apiv2.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
// System APIs
r.HandleFunc("/hello", a.handleHello).Methods("GET")
}
@ -375,6 +378,13 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
mlog.Int("block_count", len(blocks)),
)
var bErr error
blocks, bErr = a.app.ApplyCloudLimits(blocks)
if bErr != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", bErr)
return
}
json, err := json.Marshal(blocks)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
@ -875,7 +885,12 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, true)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
if errors.Is(err, app.ErrViewsLimitReached) {
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, err.Error(), err)
} else {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
}
return
}
@ -1408,6 +1423,10 @@ func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("blockID", blockID)
err = a.app.PatchBlock(blockID, patch, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -1489,6 +1508,10 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) {
}
err = a.app.PatchBlocks(teamID, patches, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -1830,7 +1853,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http
// File upload
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET "api/v2/files/teams/{teamID}/{boardID}/{filename} getFile
// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile
//
// Returns the contents of an uploaded file
//
@ -4002,6 +4025,10 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request)
auditRec.AddMeta("blocksCount", len(pbab.BlockIDs))
bab, err := a.app.PatchBoardsAndBlocks(pbab, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -4141,6 +4168,41 @@ func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request
auditRec.Success()
}
func (a *API) handleCloudLimits(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /limits cloudLimits
//
// Fetches the cloud limits of the server.
//
// ---
// produces:
// - application/json
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/BoardsCloudLimits"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
boardsCloudLimits, err := a.app.GetBoardsCloudLimits()
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
data, err := json.Marshal(boardsCloudLimits)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
}
func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /hello hello
//

View File

@ -1,6 +1,7 @@
package app
import (
"sync"
"time"
"github.com/mattermost/focalboard/server/auth"
@ -13,9 +14,10 @@ import (
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
@ -24,6 +26,10 @@ const (
blockChangeNotifierShutdownTimeout = time.Second * 10
)
type pluginAPI interface {
GetUsers(options *mmModel.UserGetOptions) ([]*mmModel.User, *mmModel.AppError)
}
type Services struct {
Auth *auth.Auth
Store store.Store
@ -34,6 +40,7 @@ type Services struct {
Logger *mlog.Logger
Permissions permissions.PermissionsService
SkipTemplateInit bool
PluginAPI pluginAPI
}
type App struct {
@ -47,6 +54,10 @@ type App struct {
notifications *notify.Service
logger *mlog.Logger
blockChangeNotifier *utils.CallbackQueue
pluginAPI pluginAPI
cardLimitMux sync.RWMutex
cardLimit int
}
func (a *App) SetConfig(config *config.Configuration) {
@ -69,7 +80,20 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services)
notifications: services.Notifications,
logger: services.Logger,
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
pluginAPI: services.PluginAPI,
}
app.initialize(services.SkipTemplateInit)
return app
}
func (a *App) CardLimit() int {
a.cardLimitMux.RLock()
defer a.cardLimitMux.RUnlock()
return a.cardLimit
}
func (a *App) SetCardLimit(cardLimit int) {
a.cardLimitMux.Lock()
defer a.cardLimitMux.Unlock()
a.cardLimit = cardLimit
}

View File

@ -1,7 +1,6 @@
package app
import (
"fmt"
"testing"
"github.com/golang/mock/gomock"
@ -114,7 +113,6 @@ func TestRegisterUser(t *testing.T) {
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
fmt.Println(test.email)
err := th.App.RegisterUser(test.userName, test.email, test.password)
if test.isError {
require.Error(t, err)

View File

@ -13,6 +13,8 @@ import (
)
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
var ErrViewsLimitReached = errors.New("views limit reached for board")
var ErrPatchUpdatesLimitedCards = errors.New("patch updates cards that are limited")
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]model.Block, error) {
if boardID == "" {
@ -50,6 +52,16 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe
}
return nil
})
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed duplicating a block",
mlog.Err(uErr),
)
}
}()
return blocks, err
}
@ -60,7 +72,17 @@ func (a *App) GetBlocksWithBoardID(boardID string) ([]model.Block, error) {
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) error {
oldBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
if a.IsCloudLimited() {
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]model.Block{*oldBlock})
if lErr != nil {
return lErr
}
if containsLimitedBlocks {
return ErrPatchUpdatesLimitedCards
}
}
board, err := a.store.GetBoard(oldBlock.BoardID)
@ -76,7 +98,7 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
a.metrics.IncrementBlocksPatched(1)
block, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
a.blockChangeNotifier.Enqueue(func() error {
// broadcast on websocket
@ -93,17 +115,22 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
}
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
oldBlocks := make([]model.Block, 0, len(blockPatches.BlockIDs))
for _, blockID := range blockPatches.BlockIDs {
oldBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
}
oldBlocks = append(oldBlocks, *oldBlock)
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
if err != nil {
return err
}
err := a.store.PatchBlocks(blockPatches, modifiedByID)
if err != nil {
if a.IsCloudLimited() {
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
if err != nil {
return err
}
if containsLimitedBlocks {
return ErrPatchUpdatesLimitedCards
}
}
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
return err
}
@ -112,7 +139,7 @@ func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, mo
for i, blockID := range blockPatches.BlockIDs {
newBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
a.wsAdapter.BroadcastBlockChange(teamID, *newBlock)
a.webhook.NotifyUpdate(*newBlock)
@ -136,9 +163,20 @@ func (a *App) InsertBlock(block model.Block, modifiedByID string) error {
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(block)
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
return nil
})
}
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting a block",
mlog.Err(uErr),
)
}
}()
return err
}
@ -180,9 +218,19 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
}
}
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting blocks",
mlog.Err(err),
)
}
}()
return blocks, nil
}
@ -289,8 +337,19 @@ func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
a.metrics.IncrementBlocksDeleted(1)
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a block",
mlog.Err(err),
)
}
}()
return nil
}
@ -341,9 +400,19 @@ func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, er
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(*block)
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a block",
mlog.Err(err),
)
}
}()
return block, nil
}

View File

@ -1,12 +1,15 @@
package app
import (
"database/sql"
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/focalboard/server/model"
)
type blockError struct {
@ -48,17 +51,63 @@ func TestPatchBlocks(t *testing.T) {
defer tearDown()
t.Run("patchBlocks success scenerio", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{}
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mmModel.NewString("new title")},
},
}
block1 := model.Block{ID: "block1"}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
th.Store.EXPECT().GetBlock("block1").Return(&block1, nil)
// this call comes from the WS server notification
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.NoError(t, err)
})
t.Run("patchBlocks error scenerio", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{}
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(blockError{"error"})
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.Error(t, err, "error")
require.ErrorIs(t, err, sql.ErrNoRows)
})
t.Run("cloud limit error scenario", func(t *testing.T) {
th.App.SetCardLimit(5)
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mmModel.NewString("new title")},
},
}
block1 := model.Block{
ID: "block1",
Type: model.TypeCard,
ParentID: "board-id",
BoardID: "board-id",
UpdateAt: 100,
}
board1 := &model.Board{
ID: "board-id",
Type: model.BoardTypeOpen,
}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.ErrorIs(t, err, ErrPatchUpdatesLimitedCards)
})
}

View File

@ -1,3 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
@ -199,6 +202,18 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
}
return nil
})
if len(bab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after duplicating a board",
mlog.Err(uErr),
)
}
}()
}
return bab, members, err
}
@ -273,6 +288,15 @@ func (a *App) DeleteBoard(boardID, userID string) error {
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a board",
mlog.Err(err),
)
}
}()
return nil
}
@ -451,5 +475,14 @@ func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a board",
mlog.Err(err),
)
}
}()
return nil
}

View File

@ -44,17 +44,39 @@ func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, a
}
}
if len(newBab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after creating boards and blocks",
mlog.Err(uErr),
)
}
}()
}
return newBab, nil
}
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
oldBlocksMap := map[string]*model.Block{}
for _, blockID := range pbab.BlockIDs {
block, err := a.store.GetBlock(blockID)
if err != nil {
return nil, err
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
if err != nil {
return nil, err
}
if a.IsCloudLimited() {
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
if cErr != nil {
return nil, cErr
}
oldBlocksMap[blockID] = block
if containsLimitedBlocks {
return nil, ErrPatchUpdatesLimitedCards
}
}
oldBlocksMap := map[string]model.Block{}
for _, block := range oldBlocks {
oldBlocksMap[block.ID] = block
}
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
@ -76,7 +98,7 @@ func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID stri
a.metrics.IncrementBlocksPatched(1)
a.wsAdapter.BroadcastBlockChange(teamID, b)
a.webhook.NotifyUpdate(b)
a.notifyBlockChanged(notify.Update, &b, oldBlock, userID)
a.notifyBlockChanged(notify.Update, &b, &oldBlock, userID)
}
for _, board := range bab.Boards {
@ -122,5 +144,16 @@ func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID st
return nil
})
if len(dbab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
mlog.Err(uErr),
)
}
}()
}
return nil
}

256
server/app/cloud.go Normal file
View File

@ -0,0 +1,256 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
)
var ErrNilPluginAPI = errors.New("server not running in plugin mode")
// GetBoardsCloudLimits returns the limits of the server, and an empty
// limits struct if there are no limits set.
func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
if !a.IsCloud() {
return &model.BoardsCloudLimits{}, nil
}
productLimits, err := a.store.GetCloudLimits()
if err != nil {
return nil, err
}
usedCards, err := a.store.GetUsedCardsCount()
if err != nil {
return nil, err
}
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return nil, err
}
boardsCloudLimits := &model.BoardsCloudLimits{
UsedCards: usedCards,
CardLimitTimestamp: cardLimitTimestamp,
}
if productLimits != nil && productLimits.Boards != nil {
if productLimits.Boards.Cards != nil {
boardsCloudLimits.Cards = *productLimits.Boards.Cards
}
if productLimits.Boards.Views != nil {
boardsCloudLimits.Views = *productLimits.Boards.Views
}
}
return boardsCloudLimits, nil
}
// IsCloud returns true if the server is running as a plugin in a
// cloud licensed server.
func (a *App) IsCloud() bool {
return utils.IsCloudLicense(a.store.GetLicense())
}
// IsCloudLimited returns true if the server is running in cloud mode
// and the card limit has been set.
func (a *App) IsCloudLimited() bool {
return a.CardLimit() != 0 && a.IsCloud()
}
// SetCloudLimits sets the limits of the server.
func (a *App) SetCloudLimits(limits *mmModel.ProductLimits) error {
oldCardLimit := a.CardLimit()
// if the limit object doesn't come complete, we assume limits are
// being disabled
cardLimit := 0
if limits != nil && limits.Boards != nil && limits.Boards.Cards != nil {
cardLimit = *limits.Boards.Cards
}
if oldCardLimit != cardLimit {
a.SetCardLimit(cardLimit)
return a.doUpdateCardLimitTimestamp()
}
return nil
}
// doUpdateCardLimitTimestamp performs the update without running any
// checks.
func (a *App) doUpdateCardLimitTimestamp() error {
cardLimitTimestamp, err := a.store.UpdateCardLimitTimestamp(a.CardLimit())
if err != nil {
return err
}
a.wsAdapter.BroadcastCardLimitTimestampChange(cardLimitTimestamp)
return nil
}
// UpdateCardLimitTimestamp checks if the server is a cloud instance
// with limits applied, and if that's true, recalculates the card
// limit timestamp and propagates the new one to the connected
// clients.
func (a *App) UpdateCardLimitTimestamp() error {
if !a.IsCloudLimited() {
return nil
}
return a.doUpdateCardLimitTimestamp()
}
// getTemplateMapForBlocks gets all board ids for the blocks, and
// builds a map with the board IDs as the key and their isTemplate
// field as the value.
func (a *App) getTemplateMapForBlocks(blocks []model.Block) (map[string]bool, error) {
boardMap := map[string]*model.Board{}
for _, block := range blocks {
if _, ok := boardMap[block.BoardID]; !ok {
board, err := a.store.GetBoard(block.BoardID)
if err != nil {
return nil, err
}
boardMap[block.BoardID] = board
}
}
templateMap := map[string]bool{}
for boardID, board := range boardMap {
templateMap[boardID] = board.IsTemplate
}
return templateMap, nil
}
// ApplyCloudLimits takes a set of blocks and, if the server is cloud
// limited, limits those that are outside of the card limit and don't
// belong to a template.
func (a *App) ApplyCloudLimits(blocks []model.Block) ([]model.Block, error) {
// if there is no limit currently being applied, return
if !a.IsCloudLimited() {
return blocks, nil
}
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return nil, err
}
templateMap, err := a.getTemplateMapForBlocks(blocks)
if err != nil {
return nil, err
}
limitedBlocks := make([]model.Block, len(blocks))
for i, block := range blocks {
// if the block belongs to a template, it will never be
// limited
if isTemplate, ok := templateMap[block.BoardID]; ok && isTemplate {
limitedBlocks[i] = block
continue
}
if block.ShouldBeLimited(cardLimitTimestamp) {
limitedBlocks[i] = block.GetLimited()
} else {
limitedBlocks[i] = block
}
}
return limitedBlocks, nil
}
// ContainsLimitedBlocks checks if a list of blocks contain any block
// that references a limited card.
func (a *App) ContainsLimitedBlocks(blocks []model.Block) (bool, error) {
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return false, err
}
if cardLimitTimestamp == 0 {
return false, nil
}
cards := []model.Block{}
cardIDMap := map[string]bool{}
for _, block := range blocks {
switch block.Type {
case model.TypeCard:
cards = append(cards, block)
default:
cardIDMap[block.ParentID] = true
}
}
cardIDs := []string{}
// if the card is already present on the set, we don't need to
// fetch it from the database
for cardID := range cardIDMap {
alreadyPresent := false
for _, card := range cards {
if card.ID == cardID {
alreadyPresent = true
break
}
}
if !alreadyPresent {
cardIDs = append(cardIDs, cardID)
}
}
if len(cardIDs) > 0 {
fetchedCards, fErr := a.store.GetBlocksByIDs(cardIDs)
if fErr != nil {
return false, fErr
}
cards = append(cards, fetchedCards...)
}
templateMap, err := a.getTemplateMapForBlocks(cards)
if err != nil {
return false, err
}
for _, card := range cards {
isTemplate, ok := templateMap[card.BoardID]
if !ok {
return false, newErrBoardNotFoundInTemplateMap(card.BoardID)
}
// if the block belongs to a template, it will never be
// limited
if isTemplate {
continue
}
if card.ShouldBeLimited(cardLimitTimestamp) {
return true, nil
}
}
return false, nil
}
type errBoardNotFoundInTemplateMap struct {
id string
}
func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap {
return &errBoardNotFoundInTemplateMap{id}
}
func (eb *errBoardNotFoundInTemplateMap) Error() string {
return fmt.Sprintf("board %q not found in template map", eb.id)
}

642
server/app/cloud_test.go Normal file
View File

@ -0,0 +1,642 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/focalboard/server/model"
)
func TestIsCloud(t *testing.T) {
t.Run("if it's not running on plugin mode", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().GetLicense().Return(nil)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode but the license is incomplete", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mmModel.License{}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
fakeLicense = &mmModel.License{Features: &mmModel.Features{}}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode, with a non-cloud license", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(false)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode with a cloud license", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.True(t, th.App.IsCloud())
})
}
func TestIsCloudLimited(t *testing.T) {
t.Run("if no limit has been set, it should be false", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
require.False(t, th.App.IsCloudLimited())
})
t.Run("if the limit is set, it should be true", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.App.SetCardLimit(5)
require.True(t, th.App.IsCloudLimited())
})
}
func TestSetCloudLimits(t *testing.T) {
t.Run("if the limits are empty, it should do nothing", func(t *testing.T) {
t.Run("limits empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
require.NoError(t, th.App.SetCloudLimits(nil))
require.Zero(t, th.App.CardLimit())
})
t.Run("limits not empty but board limits empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
limits := &mmModel.ProductLimits{}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Zero(t, th.App.CardLimit())
})
t.Run("limits not empty but board limits values empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
limits := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Zero(t, th.App.CardLimit())
})
})
t.Run("if the limits are not empty, it should update them and calculate the new timestamp", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
newCardLimitTimestamp := int64(27)
th.Store.EXPECT().UpdateCardLimitTimestamp(5).Return(newCardLimitTimestamp, nil)
limits := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{Cards: mmModel.NewInt(5)},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Equal(t, 5, th.App.CardLimit())
})
t.Run("if the limits are already set and we unset them, the timestamp will be unset too", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(20)
th.Store.EXPECT().UpdateCardLimitTimestamp(0)
require.NoError(t, th.App.SetCloudLimits(nil))
require.Zero(t, th.App.CardLimit())
})
t.Run("if the limits are already set and we try to set the same ones again", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(20)
// the call to update card limit timestamp should not happen
// as the limits didn't change
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
limits := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{Cards: mmModel.NewInt(20)},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Equal(t, 20, th.App.CardLimit())
})
}
func TestUpdateCardLimitTimestamp(t *testing.T) {
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
t.Run("if the server is a cloud instance but not limited, it should do nothing", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
// the license check will not be done as the limit not being
// set is enough for the method to return
th.Store.EXPECT().GetLicense().Times(0)
// no call to UpdateCardLimitTimestamp should happen as the
// method should shortcircuit if not cloud limited
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
require.NoError(t, th.App.UpdateCardLimitTimestamp())
})
t.Run("if the server is a cloud instance and the timestamp is set, it should run the update", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(5)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
// no call to UpdateCardLimitTimestamp should happen as the
// method should shortcircuit if not cloud limited
th.Store.EXPECT().UpdateCardLimitTimestamp(5)
require.NoError(t, th.App.UpdateCardLimitTimestamp())
})
}
func TestGetTemplateMapForBlocks(t *testing.T) {
t.Run("should fetch the necessary boards from the database", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
board2 := &model.Board{
ID: "board2",
Type: model.BoardTypeOpen,
IsTemplate: false,
}
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
},
{
ID: "text2",
Type: model.TypeText,
ParentID: "card2",
BoardID: "board2",
},
}
th.Store.EXPECT().
GetBoard("board1").
Return(board1, nil).
Times(1)
th.Store.EXPECT().
GetBoard("board2").
Return(board2, nil).
Times(1)
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
require.NoError(t, err)
require.Len(t, templateMap, 2)
require.Contains(t, templateMap, "board1")
require.True(t, templateMap["board1"])
require.Contains(t, templateMap, "board2")
require.False(t, templateMap["board2"])
})
t.Run("should fail if the board is not in the database", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
},
}
th.Store.EXPECT().
GetBoard("board1").
Return(nil, sql.ErrNoRows).
Times(1)
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, templateMap)
})
}
func TestApplyCloudLimits(t *testing.T) {
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: false,
}
template := &model.Board{
ID: "template",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 100,
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
},
{
ID: "card-from-template",
Type: model.TypeCard,
ParentID: "template",
BoardID: "template",
UpdateAt: 1,
},
}
t.Run("if the server is not limited, it should return the blocks untouched", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
newBlocks, err := th.App.ApplyCloudLimits(blocks)
require.NoError(t, err)
require.ElementsMatch(t, blocks, newBlocks)
})
t.Run("if the server is limited, it should limit the blocks that are beyond the card limit timestamp", func(t *testing.T) {
findBlock := func(blocks []model.Block, id string) model.Block {
for _, block := range blocks {
if block.ID == id {
return block
}
}
require.FailNow(t, "block %s not found", id)
return model.Block{} // this should never be reached
}
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(5)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil).Times(1)
th.Store.EXPECT().GetBoard("template").Return(template, nil).Times(1)
newBlocks, err := th.App.ApplyCloudLimits(blocks)
require.NoError(t, err)
// should be limited as it's beyond the threshold
require.True(t, findBlock(newBlocks, "card1").Limited)
// only cards are limited
require.False(t, findBlock(newBlocks, "text1").Limited)
// should not be limited as it's not beyond the threshold
require.False(t, findBlock(newBlocks, "card2").Limited)
// cards belonging to templates are never limited
require.False(t, findBlock(newBlocks, "card-from-template").Limited)
})
}
func TestContainsLimitedBlocks(t *testing.T) {
// for all the following tests, the timestamp will be set to 150,
// which means that blocks with an UpdateAt set to 100 will be
// outside the active window and possibly limited, and blocks with
// UpdateAt set to 200 will not
t.Run("should return false if the card limit timestamp is zero", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(0), nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should return true if the block set contains a card that is limited", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypePrivate,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.True(t, containsLimitedBlocks)
})
t.Run("should return false if that same block belongs to a template", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should return true if the block contains a content block that belongs to a card that should be limited", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 200,
},
}
card1 := model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]model.Block{card1}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.True(t, containsLimitedBlocks)
})
t.Run("should return false if that same block belongs to a card that is inside the active window", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 200,
},
}
card1 := model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]model.Block{card1}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should reach to the database to fetch the necessary information only in an efficient way", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []model.Block{
// a content block that references a card that needs
// fetching
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 100,
},
// a board that needs fetching referenced by a card and a content block
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
// per timestamp should be limited but the board is a
// template
UpdateAt: 100,
},
{
ID: "text2",
Type: model.TypeText,
ParentID: "card2",
BoardID: "board2",
UpdateAt: 200,
},
// a content block that references a card and a board,
// both absent
{
ID: "image3",
Type: model.TypeImage,
ParentID: "card3",
BoardID: "board3",
UpdateAt: 100,
},
}
card1 := model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
}
card3 := model.Block{
ID: "card3",
Type: model.TypeCard,
ParentID: "board3",
BoardID: "board3",
UpdateAt: 200,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
board2 := &model.Board{
ID: "board2",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
board3 := &model.Board{
ID: "board3",
Type: model.BoardTypePrivate,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs(gomock.InAnyOrder([]string{"card1", "card3"})).Return([]model.Block{card1, card3}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
th.Store.EXPECT().GetBoard("board2").Return(board2, nil)
th.Store.EXPECT().GetBoard("board3").Return(board3, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
}

View File

@ -40,6 +40,16 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
if err == nil && string(peek) == legacyFileBegin {
a.logger.Debug("importing legacy archive")
_, errImport := a.ImportBoardJSONL(br, opt)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing a legacy file",
mlog.Err(err),
)
}
}()
return errImport
}
@ -98,6 +108,15 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
mlog.String("dir", dir),
mlog.String("filename", filename),
)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing an archive",
mlog.Err(err),
)
}
}()
}
}

View File

@ -730,3 +730,19 @@ func (c *Client) ImportArchive(teamID string, data io.Reader) *Response {
return BuildResponse(r)
}
func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) {
r, err := c.DoAPIGet("/limits", "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
var limits *model.BoardsCloudLimits
err = json.NewDecoder(r.Body).Decode(&limits)
if err != nil {
return nil, BuildErrorResponse(r, err)
}
return limits, BuildResponse(r)
}

View File

@ -30,6 +30,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect

View File

@ -210,6 +210,7 @@ github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO
github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/blevesearch/bleve/v2 v2.3.2/go.mod h1:96+xE5pZUOsr3Y4vHzV1cBC837xZCpwLlX0hrrxnvIg=
github.com/blevesearch/bleve_index_api v1.0.1/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4=

View File

@ -62,6 +62,10 @@ type Block struct {
// The board id that the block belongs to
// required: true
BoardID string `json:"boardId"`
// Indicates if the card is limited
// required: false
Limited bool `json:"limited,omitempty"`
}
// BlockPatch is a patch for modify blocks
@ -210,3 +214,33 @@ func StampModificationMetadata(userID string, blocks []Block, auditRec *audit.Re
}
}
}
func (b Block) ShouldBeLimited(cardLimitTimestamp int64) bool {
return b.Type == TypeCard &&
b.UpdateAt < cardLimitTimestamp
}
// Returns a limited version of the block that doesn't contain the
// contents of the block, only its IDs and type.
func (b Block) GetLimited() Block {
newBlock := Block{
Title: b.Title,
ID: b.ID,
ParentID: b.ParentID,
Schema: b.Schema,
Type: b.Type,
CreateAt: b.CreateAt,
UpdateAt: b.UpdateAt,
DeleteAt: b.DeleteAt,
WorkspaceID: b.WorkspaceID,
Limited: true,
}
if iconField, ok := b.Fields["icon"]; ok {
newBlock.Fields = map[string]interface{}{
"icon": iconField,
}
}
return newBlock
}

27
server/model/cloud.go Normal file
View File

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const LimitUnlimited = 0
// BoardsCloudLimits is the representation of the limits for the
// Boards server
// swagger:model
type BoardsCloudLimits struct {
// The maximum number of cards on the server
// required: true
Cards int `json:"cards"`
// The current number of cards on the server
// required: true
UsedCards int `json:"used_cards"`
// The updated_at timestamp of the limit card
// required: true
CardLimitTimestamp int64 `json:"card_limit_timestamp"`
// The maximum number of views for each board
// required: true
Views int `json:"views"`
}

View File

@ -4,10 +4,15 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
apierrors "github.com/mattermost/mattermost-plugin-api/errors"
)
// ErrBlocksFromDifferentBoards is an error type that can be returned
// when a set of blocks belong to different boards.
var ErrBlocksFromDifferentBoards = errors.New("blocks belong to different boards")
// ErrNotFound is an error type that can be returned by store APIs when a query unexpectedly fetches no records.
type ErrNotFound struct {
resource string
@ -47,3 +52,30 @@ func IsErrNotFound(err error) bool {
// check if this is a plugin API error
return errors.Is(err, apierrors.ErrNotFound)
}
// ErrNotAllFound is an error type that can be returned by store APIs
// when a query that should fetch a certain amount of records
// unexpectedly fetches less.
type ErrNotAllFound struct {
resources []string
}
func NewErrNotAllFound(resources []string) *ErrNotAllFound {
return &ErrNotAllFound{
resources: resources,
}
}
func (na *ErrNotAllFound) Error() string {
return fmt.Sprintf("not all instances in {%s} found", strings.Join(na.resources, ", "))
}
// IsErrNotAllFound returns true if `err` is or wraps a ErrNotAllFound.
func IsErrNotAllFound(err error) bool {
if err == nil {
return false
}
var na *ErrNotAllFound
return errors.As(err, &na)
}

View File

@ -9,7 +9,10 @@ import (
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
pluginapi "github.com/mattermost/mattermost-plugin-api"
)
type Params struct {
@ -21,6 +24,8 @@ type Params struct {
WSAdapter ws.Adapter
NotifyBackends []notify.Backend
PermissionsService permissions.PermissionsService
PluginAPI plugin.API
Client *pluginapi.Client
}
func (p Params) CheckValid() error {

View File

@ -33,9 +33,8 @@ import (
"github.com/mattermost/focalboard/server/ws"
"github.com/oklog/run"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
@ -138,6 +137,7 @@ func New(params Params) (*Server, error) {
Notifications: notificationService,
Logger: params.Logger,
Permissions: params.PermissionsService,
PluginAPI: params.PluginAPI,
SkipTemplateInit: utils.IsRunningUnitTests(),
}
app := app.New(params.Cfg, wsAdapter, appServices)

View File

@ -465,3 +465,7 @@ func (s *MattermostAuthLayer) SaveFileInfo(fileInfo *mmModel.FileInfo) error {
func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
return s.pluginAPI.GetLicense()
}
func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) {
return s.pluginAPI.GetCloudLimits()
}

View File

@ -399,6 +399,21 @@ func (mr *MockStoreMockRecorder) GetBlockHistoryDescendants(arg0, arg1 interface
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistoryDescendants", reflect.TypeOf((*MockStore)(nil).GetBlockHistoryDescendants), arg0, arg1)
}
// GetBlocksByIDs mocks base method.
func (m *MockStore) GetBlocksByIDs(arg0 []string) ([]model.Block, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBlocksByIDs", arg0)
ret0, _ := ret[0].([]model.Block)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetBlocksByIDs indicates an expected call of GetBlocksByIDs.
func (mr *MockStoreMockRecorder) GetBlocksByIDs(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksByIDs", reflect.TypeOf((*MockStore)(nil).GetBlocksByIDs), arg0)
}
// GetBlocksForBoard mocks base method.
func (m *MockStore) GetBlocksForBoard(arg0 string) ([]model.Block, error) {
m.ctrl.T.Helper()
@ -566,6 +581,21 @@ func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1 interface{})
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1)
}
// GetCardLimitTimestamp mocks base method.
func (m *MockStore) GetCardLimitTimestamp() (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCardLimitTimestamp")
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetCardLimitTimestamp indicates an expected call of GetCardLimitTimestamp.
func (mr *MockStoreMockRecorder) GetCardLimitTimestamp() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).GetCardLimitTimestamp))
}
// GetCategory mocks base method.
func (m *MockStore) GetCategory(arg0 string) (*model.Category, error) {
m.ctrl.T.Helper()
@ -581,6 +611,21 @@ func (mr *MockStoreMockRecorder) GetCategory(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCategory", reflect.TypeOf((*MockStore)(nil).GetCategory), arg0)
}
// GetCloudLimits mocks base method.
func (m *MockStore) GetCloudLimits() (*model0.ProductLimits, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCloudLimits")
ret0, _ := ret[0].(*model0.ProductLimits)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetCloudLimits indicates an expected call of GetCloudLimits.
func (mr *MockStoreMockRecorder) GetCloudLimits() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCloudLimits", reflect.TypeOf((*MockStore)(nil).GetCloudLimits))
}
// GetFileInfo mocks base method.
func (m *MockStore) GetFileInfo(arg0 string) (*model0.FileInfo, error) {
m.ctrl.T.Helper()
@ -895,6 +940,21 @@ func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0, arg1 interface{}) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0, arg1)
}
// GetUsedCardsCount mocks base method.
func (m *MockStore) GetUsedCardsCount() (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUsedCardsCount")
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUsedCardsCount indicates an expected call of GetUsedCardsCount.
func (mr *MockStoreMockRecorder) GetUsedCardsCount() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedCardsCount", reflect.TypeOf((*MockStore)(nil).GetUsedCardsCount))
}
// GetUserByEmail mocks base method.
func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) {
m.ctrl.T.Helper()
@ -1259,6 +1319,21 @@ func (mr *MockStoreMockRecorder) UndeleteBoard(arg0, arg1 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBoard", reflect.TypeOf((*MockStore)(nil).UndeleteBoard), arg0, arg1)
}
// UpdateCardLimitTimestamp mocks base method.
func (m *MockStore) UpdateCardLimitTimestamp(arg0 int) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateCardLimitTimestamp", arg0)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateCardLimitTimestamp indicates an expected call of UpdateCardLimitTimestamp.
func (mr *MockStoreMockRecorder) UpdateCardLimitTimestamp(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).UpdateCardLimitTimestamp), arg0)
}
// UpdateCategory mocks base method.
func (m *MockStore) UpdateCategory(arg0 model.Category) error {
m.ctrl.T.Helper()

View File

@ -100,6 +100,32 @@ func (s *SQLStore) getBlocksWithParent(db sq.BaseRunner, boardID, parentID strin
return s.blocksFromRows(rows)
}
func (s *SQLStore) getBlocksByIDs(db sq.BaseRunner, ids []string) ([]model.Block, error) {
query := s.getQueryBuilder(db).
Select(s.blockFields()...).
From(s.tablePrefix + "blocks").
Where(sq.Eq{"id": ids})
rows, err := query.Query()
if err != nil {
s.logger.Error(`GetBlocksByIDs ERROR`, mlog.Err(err))
return nil, err
}
defer s.CloseRows(rows)
blocks, err := s.blocksFromRows(rows)
if err != nil {
return nil, err
}
if len(blocks) != len(ids) {
return nil, model.NewErrNotAllFound(ids)
}
return blocks, nil
}
func (s *SQLStore) getBlocksWithBoardID(db sq.BaseRunner, boardID string) ([]model.Block, error) {
query := s.getQueryBuilder(db).
Select(s.blockFields()...).

View File

@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"errors"
"strconv"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store"
)
var ErrInvalidCardLimitValue = errors.New("card limit value is invalid")
// activeCardsQuery applies the necessary filters to the query for it
// to fetch an active cards window if the cardLimit is set, or all the
// active cards if it's 0.
func (s *SQLStore) activeCardsQuery(builder sq.StatementBuilderType, selectStr string, cardLimit int) sq.SelectBuilder {
query := builder.
Select(selectStr).
From(s.tablePrefix + "blocks b").
Join(s.tablePrefix + "boards bd on b.board_id=bd.id").
Where(sq.Eq{
"b.delete_at": 0,
"b.type": model.TypeCard,
"bd.is_template": false,
})
if cardLimit != 0 {
query = query.
Limit(1).
Offset(uint64(cardLimit - 1))
}
return query
}
// getUsedCardsCount returns the amount of active cards in the server.
func (s *SQLStore) getUsedCardsCount(db sq.BaseRunner) (int, error) {
row := s.activeCardsQuery(s.getQueryBuilder(db), "count(b.id)", 0).
QueryRow()
var usedCards int
err := row.Scan(&usedCards)
if err != nil {
return 0, err
}
return usedCards, nil
}
// getCardLimitTimestamp returns the timestamp value from the
// system_settings table or zero if it doesn't exist.
func (s *SQLStore) getCardLimitTimestamp(db sq.BaseRunner) (int64, error) {
scanner := s.getQueryBuilder(db).
Select("value").
From(s.tablePrefix + "system_settings").
Where(sq.Eq{"id": store.CardLimitTimestampSystemKey}).
QueryRow()
var result string
err := scanner.Scan(&result)
if errors.Is(sql.ErrNoRows, err) {
return 0, nil
}
if err != nil {
return 0, err
}
cardLimitTimestamp, err := strconv.Atoi(result)
if err != nil {
return 0, ErrInvalidCardLimitValue
}
return int64(cardLimitTimestamp), nil
}
// updateCardLimitTimestamp updates the card limit value in the
// system_settings table with the timestamp of the nth last updated
// card, being nth the value of the cardLimit parameter. If cardLimit
// is zero, the timestamp will be set to zero.
func (s *SQLStore) updateCardLimitTimestamp(db sq.BaseRunner, cardLimit int) (int64, error) {
query := s.getQueryBuilder(db).
Insert(s.tablePrefix+"system_settings").
Columns("id", "value")
var value interface{} = 0
if cardLimit != 0 {
value = s.activeCardsQuery(sq.StatementBuilder, "b.update_at", cardLimit).
OrderBy("b.update_at DESC").
Prefix("COALESCE((").Suffix("), 0)")
}
query = query.Values(store.CardLimitTimestampSystemKey, value)
if s.dbType == model.MysqlDBType {
query = query.Suffix("ON DUPLICATE KEY UPDATE value = ?", value)
} else {
query = query.Suffix(
`ON CONFLICT (id)
DO UPDATE SET value = EXCLUDED.value`,
)
}
result, err := query.Exec()
if err != nil {
return 0, err
}
if _, err := result.RowsAffected(); err != nil {
return 0, err
}
return s.getCardLimitTimestamp(db)
}

View File

@ -294,6 +294,11 @@ func (s *SQLStore) GetBlockHistoryDescendants(boardID string, opts model.QueryBl
}
func (s *SQLStore) GetBlocksByIDs(ids []string) ([]model.Block, error) {
return s.getBlocksByIDs(s.db, ids)
}
func (s *SQLStore) GetBlocksForBoard(boardID string) ([]model.Block, error) {
return s.getBlocksForBoard(s.db, boardID)
@ -349,11 +354,21 @@ func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string) ([]*mod
}
func (s *SQLStore) GetCardLimitTimestamp() (int64, error) {
return s.getCardLimitTimestamp(s.db)
}
func (s *SQLStore) GetCategory(id string) (*model.Category, error) {
return s.getCategory(s.db, id)
}
func (s *SQLStore) GetCloudLimits() (*mmModel.ProductLimits, error) {
return s.getCloudLimits(s.db)
}
func (s *SQLStore) GetFileInfo(id string) (*mmModel.FileInfo, error) {
return s.getFileInfo(s.db, id)
@ -459,6 +474,11 @@ func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Boa
}
func (s *SQLStore) GetUsedCardsCount() (int, error) {
return s.getUsedCardsCount(s.db)
}
func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) {
return s.getUserByEmail(s.db, email)
@ -769,6 +789,11 @@ func (s *SQLStore) UndeleteBoard(boardID string, modifiedBy string) error {
}
func (s *SQLStore) UpdateCardLimitTimestamp(cardLimit int) (int64, error) {
return s.updateCardLimitTimestamp(s.db, cardLimit)
}
func (s *SQLStore) UpdateCategory(category model.Category) error {
return s.updateCategory(s.db, category)

View File

@ -122,3 +122,7 @@ func (s *SQLStore) escapeField(fieldName string) string {
func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License {
return nil
}
func (s *SQLStore) getCloudLimits(db sq.BaseRunner) (*mmModel.ProductLimits, error) {
return nil, nil
}

View File

@ -21,5 +21,6 @@ func TestSQLStore(t *testing.T) {
t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) })
t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) })
t.Run("DataRetention", func(t *testing.T) { storetests.StoreTestDataRetention(t, SetupTests) })
t.Run("CloudStore", func(t *testing.T) { storetests.StoreTestCloudStore(t, SetupTests) })
t.Run("StoreTestFileStore", func(t *testing.T) { storetests.StoreTestFileStore(t, SetupTests) })
}

View File

@ -24,6 +24,10 @@ func (s *SQLStore) IsErrNotFound(err error) bool {
return model.IsErrNotFound(err)
}
func (s *SQLStore) IsErrNotAllFound(err error) bool {
return model.IsErrNotAllFound(err)
}
func (s *SQLStore) MarshalJSONB(data interface{}) ([]byte, error) {
b, err := json.Marshal(data)
if err != nil {

View File

@ -10,10 +10,13 @@ import (
mmModel "github.com/mattermost/mattermost-server/v6/model"
)
const CardLimitTimestampSystemKey = "card_limit_timestamp"
// Store represents the abstraction of the data storage.
type Store interface {
GetBlocksWithParentAndType(boardID, parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(boardID, parentID string) ([]model.Block, error)
GetBlocksByIDs(ids []string) ([]model.Block, error)
GetBlocksWithBoardID(boardID string) ([]model.Block, error)
GetBlocksWithType(boardID, blockType string) ([]model.Block, error)
GetSubTree2(boardID, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error)
@ -139,7 +142,12 @@ type Store interface {
// @withTransaction
RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error)
GetUsedCardsCount() (int, error)
GetCardLimitTimestamp() (int64, error)
UpdateCardLimitTimestamp(cardLimit int) (int64, error)
DBType() string
GetLicense() *mmModel.License
GetCloudLimits() (*mmModel.ProductLimits, error)
}

View File

@ -0,0 +1,331 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package storetests
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/focalboard/server/model"
storeservice "github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/utils"
)
func StoreTestCloudStore(t *testing.T, setup func(t *testing.T) (storeservice.Store, func())) {
t.Run("GetUsedCardsCount", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testGetUsedCardsCount(t, store)
})
t.Run("TestGetCardLimitTimestamp", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testGetCardLimitTimestamp(t, store)
})
t.Run("TestUpdateCardLimitTimestamp", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testUpdateCardLimitTimestamp(t, store)
})
}
func testGetUsedCardsCount(t *testing.T, store storeservice.Store) {
userID := "user-id"
t.Run("should return zero when no cards have been created", func(t *testing.T) {
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Zero(t, count)
})
t.Run("should correctly return the cards of all boards", func(t *testing.T) {
// two boards
for _, boardID := range []string{"board1", "board2"} {
boardType := model.BoardTypeOpen
if boardID == "board2" {
boardType = model.BoardTypePrivate
}
board := &model.Board{
ID: boardID,
TeamID: testTeamID,
Type: boardType,
}
_, err := store.InsertBoard(board, userID)
require.NoError(t, err)
}
// board 1 has three cards
for _, cardID := range []string{"card1", "card2", "card3"} {
card := model.Block{
ID: cardID,
ParentID: "board1",
BoardID: "board1",
Type: model.TypeCard,
}
require.NoError(t, store.InsertBlock(&card, userID))
}
// board 2 has two cards
for _, cardID := range []string{"card4", "card5"} {
card := model.Block{
ID: cardID,
ParentID: "board2",
BoardID: "board2",
Type: model.TypeCard,
}
require.NoError(t, store.InsertBlock(&card, userID))
}
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Equal(t, 5, count)
})
t.Run("should not take into account content blocks", func(t *testing.T) {
// we add a couple of content blocks
text := model.Block{
ID: "text-id",
ParentID: "card1",
BoardID: "board1",
Type: model.TypeText,
}
require.NoError(t, store.InsertBlock(&text, userID))
view := model.Block{
ID: "view-id",
ParentID: "board1",
BoardID: "board1",
Type: model.TypeView,
}
require.NoError(t, store.InsertBlock(&view, userID))
// and count should not change
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Equal(t, 5, count)
})
t.Run("should not take into account cards belonging to templates", func(t *testing.T) {
// we add a template with cards
templateID := "template-id"
boardTemplate := model.Block{
ID: templateID,
BoardID: templateID,
Type: model.TypeBoard,
Fields: map[string]interface{}{
"isTemplate": true,
},
}
require.NoError(t, store.InsertBlock(&boardTemplate, userID))
for _, cardID := range []string{"card6", "card7", "card8"} {
card := model.Block{
ID: cardID,
ParentID: templateID,
BoardID: templateID,
Type: model.TypeCard,
}
require.NoError(t, store.InsertBlock(&card, userID))
}
// and count should still be the same
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Equal(t, 5, count)
})
t.Run("should not take into account deleted cards", func(t *testing.T) {
// we create a ninth card on the first board
card9 := model.Block{
ID: "card9",
ParentID: "board1",
BoardID: "board1",
Type: model.TypeCard,
DeleteAt: utils.GetMillis(),
}
require.NoError(t, store.InsertBlock(&card9, userID))
// and count should still be the same
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Equal(t, 5, count)
})
t.Run("should not take into account cards from deleted boards", func(t *testing.T) {
require.NoError(t, store.DeleteBoard("board2", "user-id"))
count, err := store.GetUsedCardsCount()
require.NoError(t, err)
require.Equal(t, 3, count)
})
}
func testGetCardLimitTimestamp(t *testing.T, store storeservice.Store) {
t.Run("should return 0 if there is no entry in the database", func(t *testing.T) {
rawValue, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey)
require.NoError(t, err)
require.Equal(t, "", rawValue)
cardLimitTimestamp, err := store.GetCardLimitTimestamp()
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
})
t.Run("should return an int64 representation of the value", func(t *testing.T) {
require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "1234"))
cardLimitTimestamp, err := store.GetCardLimitTimestamp()
require.NoError(t, err)
require.Equal(t, int64(1234), cardLimitTimestamp)
})
t.Run("should return an invalid value error if the value is not a number", func(t *testing.T) {
require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "abc"))
cardLimitTimestamp, err := store.GetCardLimitTimestamp()
require.ErrorContains(t, err, "card limit value is invalid")
require.Zero(t, cardLimitTimestamp)
})
}
func testUpdateCardLimitTimestamp(t *testing.T, store storeservice.Store) {
userID := "user-id"
// two boards
for _, boardID := range []string{"board1", "board2"} {
boardType := model.BoardTypeOpen
if boardID == "board2" {
boardType = model.BoardTypePrivate
}
board := &model.Board{
ID: boardID,
TeamID: testTeamID,
Type: boardType,
}
_, err := store.InsertBoard(board, userID)
require.NoError(t, err)
}
// board 1 has five cards
for _, cardID := range []string{"card1", "card2", "card3", "card4", "card5"} {
card := model.Block{
ID: cardID,
ParentID: "board1",
BoardID: "board1",
Type: model.TypeCard,
}
require.NoError(t, store.InsertBlock(&card, userID))
time.Sleep(10 * time.Millisecond)
}
// board 2 has five cards
for _, cardID := range []string{"card6", "card7", "card8", "card9", "card10"} {
card := model.Block{
ID: cardID,
ParentID: "board2",
BoardID: "board2",
Type: model.TypeCard,
}
require.NoError(t, store.InsertBlock(&card, userID))
time.Sleep(10 * time.Millisecond)
}
t.Run("should set the timestamp to zero if the card limit is zero", func(t *testing.T) {
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0)
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey)
require.NoError(t, err)
require.Equal(t, "0", cardLimitTimestampStr)
})
t.Run("should correctly modify the limit several times in a row", func(t *testing.T) {
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0)
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(10)
require.NoError(t, err)
require.NotZero(t, cardLimitTimestamp)
cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey)
require.NoError(t, err)
require.NotEqual(t, "0", cardLimitTimestampStr)
cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(0)
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
cardLimitTimestampStr, err = store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey)
require.NoError(t, err)
require.Equal(t, "0", cardLimitTimestampStr)
})
t.Run("should set the correct timestamp", func(t *testing.T) {
t.Run("limit 10", func(t *testing.T) {
// we fetch the first block
card1, err := store.GetBlock("card1")
require.NoError(t, err)
// and assert that if the limit is 10, the stored
// timestamp corresponds to the card's update_at
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10)
require.NoError(t, err)
require.Equal(t, card1.UpdateAt, cardLimitTimestamp)
})
t.Run("limit 5", func(t *testing.T) {
// if the limit is 5, the timestamp should be the one from
// the sixth card (the first five are older and out of the
card6, err := store.GetBlock("card6")
require.NoError(t, err)
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(5)
require.NoError(t, err)
require.Equal(t, card6.UpdateAt, cardLimitTimestamp)
})
t.Run("limit should be zero if we have less cards than the limit", func(t *testing.T) {
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(100)
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
})
t.Run("we update the first inserted card and assert that with limit 1 that's the limit that is set", func(t *testing.T) {
time.Sleep(10 * time.Millisecond)
card1, err := store.GetBlock("card1")
require.NoError(t, err)
card1.Title = "New title"
require.NoError(t, store.InsertBlock(card1, userID))
newCard1, err := store.GetBlock("card1")
require.NoError(t, err)
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(1)
require.NoError(t, err)
require.Equal(t, newCard1.UpdateAt, cardLimitTimestamp)
})
t.Run("limit should stop applying if we remove the last card", func(t *testing.T) {
initialCardLimitTimestamp, err := store.GetCardLimitTimestamp()
require.NoError(t, err)
require.NotZero(t, initialCardLimitTimestamp)
time.Sleep(10 * time.Millisecond)
require.NoError(t, store.DeleteBlock("card1", userID))
cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10)
require.NoError(t, err)
require.Zero(t, cardLimitTimestamp)
})
})
}

View File

@ -5,7 +5,7 @@ import (
"reflect"
"time"
mm_model "github.com/mattermost/mattermost-server/v6/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
)
type IDType byte
@ -27,22 +27,22 @@ const (
// with the padding stripped off, and a one character alpha prefix indicating the
// type of entity or a `7` if unknown type.
func NewID(idType IDType) string {
return string(idType) + mm_model.NewId()
return string(idType) + mmModel.NewId()
}
// GetMillis is a convenience method to get milliseconds since epoch.
func GetMillis() int64 {
return mm_model.GetMillis()
return mmModel.GetMillis()
}
// GetMillisForTime is a convenience method to get milliseconds since epoch for provided Time.
func GetMillisForTime(thisTime time.Time) int64 {
return mm_model.GetMillisForTime(thisTime)
return mmModel.GetMillisForTime(thisTime)
}
// GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch.
func GetTimeForMillis(millis int64) time.Time {
return mm_model.GetTimeForMillis(millis)
return mmModel.GetTimeForMillis(millis)
}
// SecondsToMillis is a convenience method to convert seconds to milliseconds.
@ -95,3 +95,10 @@ func Intersection(x ...[]interface{}) []interface{} {
return result
}
func IsCloudLicense(license *mmModel.License) bool {
return license != nil &&
license.Features != nil &&
license.Features.Cloud != nil &&
*license.Features.Cloud
}

View File

@ -6,19 +6,20 @@ import (
)
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"
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"
)
type Store interface {
@ -36,5 +37,6 @@ type Adapter interface {
BroadcastConfigChange(clientConfig model.ClientConfig)
BroadcastCategoryChange(category model.Category)
BroadcastCategoryBoardChange(teamID, userID string, blockCategory model.BoardCategoryWebsocketData)
BroadcastCardLimitTimestampChange(cardLimitTimestamp int64)
BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
}

View File

@ -39,6 +39,18 @@ type UpdateSubscription struct {
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"`

View File

@ -30,6 +30,7 @@ type PluginAdapterInterface interface {
BroadcastBlockChange(teamID string, block model.Block)
BroadcastBlockDelete(teamID, blockID, parentID string)
BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
BroadcastCardLimitTimestampChange(cardLimitTimestamp int64)
HandleClusterEvent(ev mmModel.PluginClusterEvent)
}
@ -593,3 +594,16 @@ func (pa *PluginAdapter) BroadcastSubscriptionChange(teamID string, 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))
}

View File

@ -55,12 +55,6 @@ type Server struct {
store Store
}
// UpdateClientConfig is sent on block updates.
type UpdateClientConfig struct {
Action string `json:"action"`
ClientConfig model.ClientConfig `json:"clientconfig"`
}
type websocketSession struct {
conn *websocket.Conn
userID string
@ -754,3 +748,7 @@ func (ws *Server) BroadcastMemberDelete(teamID, boardID, userID string) {
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.
}

View File

@ -7,7 +7,6 @@ import wsClient, {WSClient} from '../wsclient'
export default function useCardListener(onChange: (blocks: Block[]) => void, onReconnect: () => void): void {
useEffect(() => {
// ToDo: does this onChange need boards as well??
const onChangeHandler = (_: WSClient, blocks: Block[]) => onChange(blocks)
wsClient.addOnChange(onChangeHandler, 'block')
wsClient.addOnReconnect(onReconnect)

View File

@ -141,7 +141,6 @@ class OctoClient {
}
}
// ToDo: document
private teamPath(teamId?: string): string {
let teamIdToUse = teamId
if (!teamId) {
@ -618,14 +617,6 @@ class OctoClient {
return this.getBoardsWithPath(path)
}
// Boards
// ToDo: .
// - goal? make the interface show boards & blocks for boards
// - teams (maybe current team)? boards, members, user roles in the store, whatever that is
// - selectors for boards, current team, board members
// - ops to add/delete a board, add/delete board members, change roles? .
// - WS definition and implementation
async getBoards(): Promise<Board[]> {
const path = this.teamPath() + '/boards'
return this.getBoardsWithPath(path)

View File

@ -6,30 +6,6 @@ import {OctoUtils} from './octoUtils'
import {TestBlockFactory} from './test/testBlockFactory'
// ToDo: we need a way to duplicate the board first creating a new
// board and then dupliating and inserting all its blocks
// test('duplicateBlockTree: Board', async () => {
// const [blocks, board] = createBoardTree()
//
// const [newBlocks, newBoard, idMap] = OctoUtils.duplicateBlockTree(blocks, board.id)
//
// expect(newBlocks.length).toBe(blocks.length)
// expect(newSourceBlock.id).not.toBe(sourceBlock)
// expect(newSourceBlock.type).toBe(sourceBlock.type)
//
// // When duplicating a root block, the boardId should be re-mapped
// expect(newSourceBlock.boardId).not.toBe(sourceBlock.boardId)
// expect(idMap[sourceBlock.id]).toBe(newSourceBlock.id)
//
// for (const newBlock of newBlocks) {
// expect(newBlock.boardId).toBe(newSourceBlock.id)
// }
//
// for (const textBlock of newBlocks.filter((o) => o.type === 'card')) {
// expect(textBlock.parentId).toBe(newSourceBlock.id)
// }
// })
test('duplicateBlockTree: Card', async () => {
const [blocks, sourceBlock] = createCardTree()
@ -52,27 +28,6 @@ test('duplicateBlockTree: Card', async () => {
}
})
// function createBoardTree(): [Block[], Board] {
// const blocks: Block[] = []
// const board = TestBlockFactory.createBoard()
// board.id = 'board1'
// for (let i = 0; i < 5; i++) {
// const card = TestBlockFactory.createCard(board)
// card.id = `card${i}`
// blocks.push(card)
// for (let j = 0; j < 3; j++) {
// const textBlock = TestBlockFactory.createText(card)
// textBlock.id = `text${j}`
// blocks.push(textBlock)
// }
// }
// return [blocks, board]
// }
function createCardTree(): [Block[], Block] {
const blocks: Block[] = []

View File

@ -56,10 +56,6 @@ const WebsocketConnection = (props: Props) => {
}
const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => {
// ToDo: update this
// - create a selector to get user boards
// - replace the teamId check of blocks by a "is in my boards" check
/* const teamBlocks = blocks.filter((b: Block) => b.teamId === '0' || b.boardId in userBoardIds) */
const teamBlocks = blocks
batch(() => {

View File

@ -10,8 +10,6 @@ import {Constants} from "../constants"
import {RootState} from './index'
// ToDo: move this to team templates or simply templates
export const fetchGlobalTemplates = createAsyncThunk(
'globalTemplates/fetch',
async () => {

View File

@ -27,6 +27,7 @@ export type WSMessage = {
error?: string
teamId?: string
member?: BoardMember
timestamp?: number
}
export const ACTION_UPDATE_BOARD = 'UPDATE_BOARD'
@ -42,6 +43,7 @@ export const ACTION_UPDATE_CLIENT_CONFIG = 'UPDATE_CLIENT_CONFIG'
export const ACTION_UPDATE_CATEGORY = 'UPDATE_CATEGORY'
export const ACTION_UPDATE_BOARD_CATEGORY = 'UPDATE_BOARD_CATEGORY'
export const ACTION_UPDATE_SUBSCRIPTION = 'UPDATE_SUBSCRIPTION'
export const ACTION_UPDATE_CARD_LIMIT_TIMESTAMP = 'UPDATE_CARD_LIMIT_TIMESTAMP'
type WSSubscriptionMsg = {
action?: string
@ -74,6 +76,7 @@ type OnReconnectHandler = (client: WSClient) => void
type OnStateChangeHandler = (client: WSClient, state: 'init' | 'open' | 'close') => void
type OnErrorHandler = (client: WSClient, e: Event) => void
type OnConfigChangeHandler = (client: WSClient, clientConfig: ClientConfig) => void
type OnCardLimitTimestampChangeHandler = (client: WSClient, timestamp: number) => void
type FollowChangeHandler = (client: WSClient, subscription: Subscription) => void
export type ChangeHandlerType = 'block' | 'category' | 'blockCategories' | 'board' | 'boardMembers'
@ -110,6 +113,7 @@ class WSClient {
onChange: ChangeHandlers = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: []}
onError: OnErrorHandler[] = []
onConfigChange: OnConfigChangeHandler[] = []
onCardLimitTimestampChange: OnCardLimitTimestampChangeHandler[] = []
onFollowBlock: FollowChangeHandler = () => {}
onUnfollowBlock: FollowChangeHandler = () => {}
private notificationDelay = 100
@ -254,6 +258,17 @@ class WSClient {
}
}
addOnCardLimitTimestampChange(handler: OnCardLimitTimestampChangeHandler): void {
this.onCardLimitTimestampChange.push(handler)
}
removeOnCardLimitTimestampChange(handler: OnCardLimitTimestampChangeHandler): void {
const index = this.onCardLimitTimestampChange.indexOf(handler)
if (index !== -1) {
this.onCardLimitTimestampChange.splice(index, 1)
}
}
open(): void {
if (this.client !== null) {
// configure the Mattermost websocket client callbacks
@ -431,6 +446,12 @@ class WSClient {
}
}
updateCardLimitTimestampHandler(action: {action: string, timestamp: number}): void {
for (const handler of this.onCardLimitTimestampChange) {
handler(this, action.timestamp)
}
}
updateSubscriptionHandler(message: WSSubscriptionMsg): void {
Utils.log('updateSubscriptionHandler: ' + message.action + '; blockId=' + message.subscription?.blockId)