You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17:42 +02:00 
			
		
		
		
	@mention support (#1147)
This commit is contained in:
		
							
								
								
									
										33
									
								
								NOTICE.txt
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								NOTICE.txt
									
									
									
									
									
								
							| @@ -748,3 +748,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||||
| THE SOFTWARE. | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## wiggin77/merror | ||||
|  | ||||
| This product contains 'merror' by GitHub user 'wiggin77'. | ||||
|  | ||||
| Multiple Error aggregator for Go. | ||||
|  | ||||
| * HOMEPAGE: | ||||
|   * https://github.com/wiggin77/merror | ||||
|  | ||||
| * LICENSE: MIT | ||||
|  | ||||
| MIT License | ||||
|  | ||||
| Copyright (c) 2018 wiggin77 | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
| in the Software without restriction, including without limitation the rights | ||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all | ||||
| copies or substantial portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| SOFTWARE. | ||||
|   | ||||
| @@ -60,7 +60,17 @@ func runServer(port int) (*server.Server, error) { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	server, err := server.New(config, sessionToken, db, logger, "", nil) | ||||
| 	params := server.Params{ | ||||
| 		Cfg:             config, | ||||
| 		SingleUserToken: sessionToken, | ||||
| 		DBStore:         db, | ||||
| 		Logger:          logger, | ||||
| 		ServerID:        "", | ||||
| 		WSAdapter:       nil, | ||||
| 		NotifyBackends:  nil, | ||||
| 	} | ||||
|  | ||||
| 	server, err := server.New(params) | ||||
| 	if err != nil { | ||||
| 		fmt.Println("ERROR INITIALIZING THE SERVER", err) | ||||
| 		return nil, err | ||||
|   | ||||
							
								
								
									
										61
									
								
								mattermost-plugin/server/notifications.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								mattermost-plugin/server/notifications.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify/notifymentions" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify/plugindelivery" | ||||
|  | ||||
| 	pluginapi "github.com/mattermost/mattermost-plugin-api" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/model" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	botUsername    = "boards" | ||||
| 	botDisplayname = "Boards" | ||||
| 	botDescription = "Created by Boards plugin." | ||||
| ) | ||||
|  | ||||
| func createMentionsNotifyBackend(client *pluginapi.Client, serverRoot string, logger *mlog.Logger) (notify.Backend, error) { | ||||
| 	bot := &model.Bot{ | ||||
| 		Username:    botUsername, | ||||
| 		DisplayName: botDisplayname, | ||||
| 		Description: botDescription, | ||||
| 	} | ||||
| 	botID, err := client.Bot.EnsureBot(bot) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err) | ||||
| 	} | ||||
|  | ||||
| 	pluginAPI := &pluginAPIAdapter{client: client} | ||||
|  | ||||
| 	delivery := plugindelivery.New(botID, serverRoot, pluginAPI) | ||||
|  | ||||
| 	backend := notifymentions.New(delivery, logger) | ||||
|  | ||||
| 	return backend, nil | ||||
| } | ||||
|  | ||||
| type pluginAPIAdapter struct { | ||||
| 	client *pluginapi.Client | ||||
| } | ||||
|  | ||||
| func (da *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) { | ||||
| 	return da.client.Channel.GetDirect(userID1, userID2) | ||||
| } | ||||
|  | ||||
| func (da *pluginAPIAdapter) CreatePost(post *model.Post) error { | ||||
| 	return da.client.Post.CreatePost(post) | ||||
| } | ||||
|  | ||||
| func (da *pluginAPIAdapter) GetUserByID(userID string) (*model.User, error) { | ||||
| 	return da.client.User.Get(userID) | ||||
| } | ||||
|  | ||||
| func (da *pluginAPIAdapter) GetUserByUsername(name string) (*model.User, error) { | ||||
| 	return da.client.User.GetByUsername(name) | ||||
| } | ||||
| @@ -9,6 +9,7 @@ import ( | ||||
| 	"github.com/mattermost/focalboard/server/auth" | ||||
| 	"github.com/mattermost/focalboard/server/server" | ||||
| 	"github.com/mattermost/focalboard/server/services/config" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/store" | ||||
| 	"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer" | ||||
| 	"github.com/mattermost/focalboard/server/services/store/sqlstore" | ||||
| @@ -132,7 +133,22 @@ func (p *Plugin) OnActivate() error { | ||||
|  | ||||
| 	p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db)) | ||||
|  | ||||
| 	server, err := server.New(cfg, "", db, logger, serverID, p.wsPluginAdapter) | ||||
| 	mentionsBackend, err := createMentionsNotifyBackend(client, cfg.ServerRoot, logger) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error creating mentions notifications backend: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	params := server.Params{ | ||||
| 		Cfg:             cfg, | ||||
| 		SingleUserToken: "", | ||||
| 		DBStore:         db, | ||||
| 		Logger:          logger, | ||||
| 		ServerID:        serverID, | ||||
| 		WSAdapter:       p.wsPluginAdapter, | ||||
| 		NotifyBackends:  []notify.Backend{mentionsBackend}, | ||||
| 	} | ||||
|  | ||||
| 	server, err := server.New(params) | ||||
| 	if err != nil { | ||||
| 		fmt.Println("ERROR INITIALIZING THE SERVER", err) | ||||
| 		return err | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import ( | ||||
| 	"github.com/mattermost/focalboard/server/auth" | ||||
| 	"github.com/mattermost/focalboard/server/services/config" | ||||
| 	"github.com/mattermost/focalboard/server/services/metrics" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/store" | ||||
| 	"github.com/mattermost/focalboard/server/services/webhook" | ||||
| 	"github.com/mattermost/focalboard/server/ws" | ||||
| @@ -14,34 +15,37 @@ import ( | ||||
| ) | ||||
|  | ||||
| type Services struct { | ||||
| 	Auth         *auth.Auth | ||||
| 	Store        store.Store | ||||
| 	FilesBackend filestore.FileBackend | ||||
| 	Webhook      *webhook.Client | ||||
| 	Metrics      *metrics.Metrics | ||||
| 	Logger       *mlog.Logger | ||||
| 	Auth          *auth.Auth | ||||
| 	Store         store.Store | ||||
| 	FilesBackend  filestore.FileBackend | ||||
| 	Webhook       *webhook.Client | ||||
| 	Metrics       *metrics.Metrics | ||||
| 	Notifications *notify.Service | ||||
| 	Logger        *mlog.Logger | ||||
| } | ||||
|  | ||||
| type App struct { | ||||
| 	config       *config.Configuration | ||||
| 	store        store.Store | ||||
| 	auth         *auth.Auth | ||||
| 	wsAdapter    ws.Adapter | ||||
| 	filesBackend filestore.FileBackend | ||||
| 	webhook      *webhook.Client | ||||
| 	metrics      *metrics.Metrics | ||||
| 	logger       *mlog.Logger | ||||
| 	config        *config.Configuration | ||||
| 	store         store.Store | ||||
| 	auth          *auth.Auth | ||||
| 	wsAdapter     ws.Adapter | ||||
| 	filesBackend  filestore.FileBackend | ||||
| 	webhook       *webhook.Client | ||||
| 	metrics       *metrics.Metrics | ||||
| 	notifications *notify.Service | ||||
| 	logger        *mlog.Logger | ||||
| } | ||||
|  | ||||
| func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App { | ||||
| 	return &App{ | ||||
| 		config:       config, | ||||
| 		store:        services.Store, | ||||
| 		auth:         services.Auth, | ||||
| 		wsAdapter:    wsAdapter, | ||||
| 		filesBackend: services.FilesBackend, | ||||
| 		webhook:      services.Webhook, | ||||
| 		metrics:      services.Metrics, | ||||
| 		logger:       services.Logger, | ||||
| 		config:        config, | ||||
| 		store:         services.Store, | ||||
| 		auth:          services.Auth, | ||||
| 		wsAdapter:     wsAdapter, | ||||
| 		filesBackend:  services.FilesBackend, | ||||
| 		webhook:       services.Webhook, | ||||
| 		metrics:       services.Metrics, | ||||
| 		notifications: services.Notifications, | ||||
| 		logger:        services.Logger, | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,10 @@ package app | ||||
|  | ||||
| import ( | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/store" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| func (a *App) GetBlocks(c store.Container, parentID string, blockType string) ([]model.Block, error) { | ||||
| @@ -30,40 +33,64 @@ func (a *App) GetParentID(c store.Container, blockID string) (string, error) { | ||||
| } | ||||
|  | ||||
| func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, userID string) error { | ||||
| 	err := a.store.PatchBlock(c, blockID, blockPatch, userID) | ||||
| 	oldBlock, err := a.store.GetBlock(c, blockID) | ||||
| 	if err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	err = a.store.PatchBlock(c, blockID, blockPatch, userID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	a.metrics.IncrementBlocksPatched(1) | ||||
| 	block, err := a.store.GetBlock(c, blockID) | ||||
| 	if err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *block) | ||||
| 	go a.webhook.NotifyUpdate(*block) | ||||
| 	go func() { | ||||
| 		a.webhook.NotifyUpdate(*block) | ||||
| 		a.notifyBlockChanged(notify.Update, c, block, oldBlock, userID) | ||||
| 	}() | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (a *App) InsertBlock(c store.Container, block model.Block, userID string) error { | ||||
| 	err := a.store.InsertBlock(c, &block, userID) | ||||
| 	if err == nil { | ||||
| 		a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, block) | ||||
| 		a.metrics.IncrementBlocksInserted(1) | ||||
| 		go func() { | ||||
| 			a.webhook.NotifyUpdate(block) | ||||
| 			a.notifyBlockChanged(notify.Add, c, &block, nil, userID) | ||||
| 		}() | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string) error { | ||||
| 	needsNotify := make([]model.Block, 0, len(blocks)) | ||||
| 	for i := range blocks { | ||||
| 		err := a.store.InsertBlock(c, &blocks[i], userID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		needsNotify = append(needsNotify, blocks[i]) | ||||
|  | ||||
| 		a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, blocks[i]) | ||||
| 		a.metrics.IncrementBlocksInserted(len(blocks)) | ||||
| 		go a.webhook.NotifyUpdate(blocks[i]) | ||||
| 		a.metrics.IncrementBlocksInserted(1) | ||||
| 	} | ||||
|  | ||||
| 	go func() { | ||||
| 		for _, b := range needsNotify { | ||||
| 			block := b | ||||
| 			a.webhook.NotifyUpdate(block) | ||||
| 			a.notifyBlockChanged(notify.Add, c, &block, nil, userID) | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| @@ -80,7 +107,7 @@ func (a *App) GetAllBlocks(c store.Container) ([]model.Block, error) { | ||||
| } | ||||
|  | ||||
| func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string) error { | ||||
| 	parentID, err := a.GetParentID(c, blockID) | ||||
| 	block, err := a.store.GetBlock(c, blockID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| @@ -90,12 +117,69 @@ func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, parentID) | ||||
| 	a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, block.ParentID) | ||||
| 	a.metrics.IncrementBlocksDeleted(1) | ||||
|  | ||||
| 	go func() { | ||||
| 		a.notifyBlockChanged(notify.Update, c, block, block, modifiedBy) | ||||
| 	}() | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (a *App) GetBlockCountsByType() (map[string]int64, error) { | ||||
| 	return a.store.GetBlockCountsByType() | ||||
| } | ||||
|  | ||||
| func (a *App) notifyBlockChanged(action notify.Action, c store.Container, block *model.Block, oldBlock *model.Block, userID string) { | ||||
| 	if a.notifications == nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// find card and board for the changed block. | ||||
| 	board, card, err := a.getBoardAndCard(c, block) | ||||
| 	if err != nil { | ||||
| 		a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	evt := notify.BlockChangeEvent{ | ||||
| 		Action:       action, | ||||
| 		Workspace:    c.WorkspaceID, | ||||
| 		Board:        board, | ||||
| 		Card:         card, | ||||
| 		BlockChanged: block, | ||||
| 		BlockOld:     oldBlock, | ||||
| 		UserID:       userID, | ||||
| 	} | ||||
| 	a.notifications.BlockChanged(evt) | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	maxSearchDepth = 50 | ||||
| ) | ||||
|  | ||||
| // getBoardAndCard returns the first parent of type `card` and first parent of type `board` for the specified block. | ||||
| // `board` and/or `card` may return nil without error if the block does not belong to a board or card. | ||||
| func (a *App) getBoardAndCard(c store.Container, block *model.Block) (board *model.Block, card *model.Block, err error) { | ||||
| 	var count int // don't let invalid blocks hierarchy cause infinite loop. | ||||
| 	iter := block | ||||
| 	for { | ||||
| 		count++ | ||||
| 		if board == nil && iter.Type == "board" { | ||||
| 			board = iter | ||||
| 		} | ||||
|  | ||||
| 		if card == nil && iter.Type == "card" { | ||||
| 			card = iter | ||||
| 		} | ||||
|  | ||||
| 		if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth { | ||||
| 			break | ||||
| 		} | ||||
|  | ||||
| 		iter, err = a.store.GetBlock(c, iter.ParentID) | ||||
| 		if err != nil { | ||||
| 			return board, card, err | ||||
| 		} | ||||
| 	} | ||||
| 	return board, card, nil | ||||
| } | ||||
|   | ||||
| @@ -25,6 +25,7 @@ require ( | ||||
| 	github.com/spf13/jwalterweatherman v1.1.0 // indirect | ||||
| 	github.com/spf13/viper v1.7.1 | ||||
| 	github.com/stretchr/testify v1.7.0 | ||||
| 	github.com/wiggin77/merror v1.0.3 | ||||
| 	golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e | ||||
| 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | ||||
| ) | ||||
|   | ||||
| @@ -75,7 +75,15 @@ func newTestServer(singleUserToken string) *server.Server { | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	srv, err := server.New(cfg, singleUserToken, db, logger, "", nil) | ||||
|  | ||||
| 	params := server.Params{ | ||||
| 		Cfg:             cfg, | ||||
| 		SingleUserToken: singleUserToken, | ||||
| 		DBStore:         db, | ||||
| 		Logger:          logger, | ||||
| 	} | ||||
|  | ||||
| 	srv, err := server.New(params) | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
|   | ||||
| @@ -165,7 +165,14 @@ func main() { | ||||
| 		logger.Fatal("server.NewStore ERROR", mlog.Err(err)) | ||||
| 	} | ||||
|  | ||||
| 	server, err := server.New(config, singleUserToken, db, logger, "", nil) | ||||
| 	params := server.Params{ | ||||
| 		Cfg:             config, | ||||
| 		SingleUserToken: singleUserToken, | ||||
| 		DBStore:         db, | ||||
| 		Logger:          logger, | ||||
| 	} | ||||
|  | ||||
| 	server, err := server.New(params) | ||||
| 	if err != nil { | ||||
| 		logger.Fatal("server.New ERROR", mlog.Err(err)) | ||||
| 	} | ||||
| @@ -245,7 +252,14 @@ func startServer(webPath string, filesPath string, port int, singleUserToken, db | ||||
| 		logger.Fatal("server.NewStore ERROR", mlog.Err(err)) | ||||
| 	} | ||||
|  | ||||
| 	pServer, err = server.New(config, singleUserToken, db, logger, "", nil) | ||||
| 	params := server.Params{ | ||||
| 		Cfg:             config, | ||||
| 		SingleUserToken: singleUserToken, | ||||
| 		DBStore:         db, | ||||
| 		Logger:          logger, | ||||
| 	} | ||||
|  | ||||
| 	pServer, err = server.New(params) | ||||
| 	if err != nil { | ||||
| 		logger.Fatal("server.New ERROR", mlog.Err(err)) | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										46
									
								
								server/server/params.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								server/server/params.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| package server | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/services/config" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/store" | ||||
| 	"github.com/mattermost/focalboard/server/ws" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| type Params struct { | ||||
| 	Cfg             *config.Configuration | ||||
| 	SingleUserToken string | ||||
| 	DBStore         store.Store | ||||
| 	Logger          *mlog.Logger | ||||
| 	ServerID        string | ||||
| 	WSAdapter       ws.Adapter | ||||
| 	NotifyBackends  []notify.Backend | ||||
| } | ||||
|  | ||||
| func (p Params) CheckValid() error { | ||||
| 	if p.Cfg == nil { | ||||
| 		return ErrServerParam{name: "Cfg", issue: "cannot be nil"} | ||||
| 	} | ||||
|  | ||||
| 	if p.DBStore == nil { | ||||
| 		return ErrServerParam{name: "DbStore", issue: "cannot be nil"} | ||||
| 	} | ||||
|  | ||||
| 	if p.Logger == nil { | ||||
| 		return ErrServerParam{name: "Logger", issue: "cannot be nil"} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| type ErrServerParam struct { | ||||
| 	name  string | ||||
| 	issue string | ||||
| } | ||||
|  | ||||
| func (e ErrServerParam) Error() string { | ||||
| 	return fmt.Sprintf("invalid server params: %s %s", e.name, e.issue) | ||||
| } | ||||
| @@ -22,6 +22,8 @@ import ( | ||||
| 	"github.com/mattermost/focalboard/server/services/audit" | ||||
| 	"github.com/mattermost/focalboard/server/services/config" | ||||
| 	"github.com/mattermost/focalboard/server/services/metrics" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/mattermost/focalboard/server/services/notify/notifylogger" | ||||
| 	"github.com/mattermost/focalboard/server/services/scheduler" | ||||
| 	"github.com/mattermost/focalboard/server/services/store" | ||||
| 	"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer" | ||||
| @@ -60,6 +62,7 @@ type Server struct { | ||||
| 	metricsService         *metrics.Metrics | ||||
| 	metricsUpdaterTask     *scheduler.ScheduledTask | ||||
| 	auditService           *audit.Audit | ||||
| 	notificationService    *notify.Service | ||||
| 	servicesStartStopMutex sync.Mutex | ||||
|  | ||||
| 	localRouter     *mux.Router | ||||
| @@ -67,37 +70,41 @@ type Server struct { | ||||
| 	api             *api.API | ||||
| } | ||||
|  | ||||
| func New(cfg *config.Configuration, singleUserToken string, db store.Store, | ||||
| 	logger *mlog.Logger, serverID string, wsAdapter ws.Adapter) (*Server, error) { | ||||
| 	authenticator := auth.New(cfg, db) | ||||
| func New(params Params) (*Server, error) { | ||||
| 	if err := params.CheckValid(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	authenticator := auth.New(params.Cfg, params.DBStore) | ||||
|  | ||||
| 	// if no ws adapter is provided, we spin up a websocket server | ||||
| 	wsAdapter := params.WSAdapter | ||||
| 	if wsAdapter == nil { | ||||
| 		wsAdapter = ws.NewServer(authenticator, singleUserToken, cfg.AuthMode == MattermostAuthMod, logger) | ||||
| 		wsAdapter = ws.NewServer(authenticator, params.SingleUserToken, params.Cfg.AuthMode == MattermostAuthMod, params.Logger) | ||||
| 	} | ||||
|  | ||||
| 	filesBackendSettings := filestore.FileBackendSettings{} | ||||
| 	filesBackendSettings.DriverName = cfg.FilesDriver | ||||
| 	filesBackendSettings.Directory = cfg.FilesPath | ||||
| 	filesBackendSettings.AmazonS3AccessKeyId = cfg.FilesS3Config.AccessKeyID | ||||
| 	filesBackendSettings.AmazonS3SecretAccessKey = cfg.FilesS3Config.SecretAccessKey | ||||
| 	filesBackendSettings.AmazonS3Bucket = cfg.FilesS3Config.Bucket | ||||
| 	filesBackendSettings.AmazonS3PathPrefix = cfg.FilesS3Config.PathPrefix | ||||
| 	filesBackendSettings.AmazonS3Region = cfg.FilesS3Config.Region | ||||
| 	filesBackendSettings.AmazonS3Endpoint = cfg.FilesS3Config.Endpoint | ||||
| 	filesBackendSettings.AmazonS3SSL = cfg.FilesS3Config.SSL | ||||
| 	filesBackendSettings.AmazonS3SignV2 = cfg.FilesS3Config.SignV2 | ||||
| 	filesBackendSettings.AmazonS3SSE = cfg.FilesS3Config.SSE | ||||
| 	filesBackendSettings.AmazonS3Trace = cfg.FilesS3Config.Trace | ||||
| 	filesBackendSettings.DriverName = params.Cfg.FilesDriver | ||||
| 	filesBackendSettings.Directory = params.Cfg.FilesPath | ||||
| 	filesBackendSettings.AmazonS3AccessKeyId = params.Cfg.FilesS3Config.AccessKeyID | ||||
| 	filesBackendSettings.AmazonS3SecretAccessKey = params.Cfg.FilesS3Config.SecretAccessKey | ||||
| 	filesBackendSettings.AmazonS3Bucket = params.Cfg.FilesS3Config.Bucket | ||||
| 	filesBackendSettings.AmazonS3PathPrefix = params.Cfg.FilesS3Config.PathPrefix | ||||
| 	filesBackendSettings.AmazonS3Region = params.Cfg.FilesS3Config.Region | ||||
| 	filesBackendSettings.AmazonS3Endpoint = params.Cfg.FilesS3Config.Endpoint | ||||
| 	filesBackendSettings.AmazonS3SSL = params.Cfg.FilesS3Config.SSL | ||||
| 	filesBackendSettings.AmazonS3SignV2 = params.Cfg.FilesS3Config.SignV2 | ||||
| 	filesBackendSettings.AmazonS3SSE = params.Cfg.FilesS3Config.SSE | ||||
| 	filesBackendSettings.AmazonS3Trace = params.Cfg.FilesS3Config.Trace | ||||
|  | ||||
| 	filesBackend, appErr := filestore.NewFileBackend(filesBackendSettings) | ||||
| 	if appErr != nil { | ||||
| 		logger.Error("Unable to initialize the files storage", mlog.Err(appErr)) | ||||
| 		params.Logger.Error("Unable to initialize the files storage", mlog.Err(appErr)) | ||||
|  | ||||
| 		return nil, errors.New("unable to initialize the files storage") | ||||
| 	} | ||||
|  | ||||
| 	webhookClient := webhook.NewClient(cfg, logger) | ||||
| 	webhookClient := webhook.NewClient(params.Cfg, params.Logger) | ||||
|  | ||||
| 	// Init metrics | ||||
| 	instanceInfo := metrics.InstanceInfo{ | ||||
| @@ -113,21 +120,28 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, | ||||
| 	if errAudit != nil { | ||||
| 		return nil, fmt.Errorf("unable to create the audit service: %w", errAudit) | ||||
| 	} | ||||
| 	if err := auditService.Configure(cfg.AuditCfgFile, cfg.AuditCfgJSON); err != nil { | ||||
| 	if err := auditService.Configure(params.Cfg.AuditCfgFile, params.Cfg.AuditCfgJSON); err != nil { | ||||
| 		return nil, fmt.Errorf("unable to initialize the audit service: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	appServices := app.Services{ | ||||
| 		Auth:         authenticator, | ||||
| 		Store:        db, | ||||
| 		FilesBackend: filesBackend, | ||||
| 		Webhook:      webhookClient, | ||||
| 		Metrics:      metricsService, | ||||
| 		Logger:       logger, | ||||
| 	// Init notification services | ||||
| 	notificationService, errNotify := initNotificationService(params.NotifyBackends, params.Logger) | ||||
| 	if errNotify != nil { | ||||
| 		return nil, fmt.Errorf("cannot initialize notification service: %w", errNotify) | ||||
| 	} | ||||
| 	app := app.New(cfg, wsAdapter, appServices) | ||||
|  | ||||
| 	focalboardAPI := api.NewAPI(app, singleUserToken, cfg.AuthMode, logger, auditService) | ||||
| 	appServices := app.Services{ | ||||
| 		Auth:          authenticator, | ||||
| 		Store:         params.DBStore, | ||||
| 		FilesBackend:  filesBackend, | ||||
| 		Webhook:       webhookClient, | ||||
| 		Metrics:       metricsService, | ||||
| 		Notifications: notificationService, | ||||
| 		Logger:        params.Logger, | ||||
| 	} | ||||
| 	app := app.New(params.Cfg, wsAdapter, appServices) | ||||
|  | ||||
| 	focalboardAPI := api.NewAPI(app, params.SingleUserToken, params.Cfg.AuthMode, params.Logger, auditService) | ||||
|  | ||||
| 	// Local router for admin APIs | ||||
| 	localRouter := mux.NewRouter() | ||||
| @@ -135,18 +149,19 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, | ||||
|  | ||||
| 	// Init workspace | ||||
| 	if _, err := app.GetRootWorkspace(); err != nil { | ||||
| 		logger.Error("Unable to get root workspace", mlog.Err(err)) | ||||
| 		params.Logger.Error("Unable to get root workspace", mlog.Err(err)) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	webServer := web.NewServer(cfg.WebPath, cfg.ServerRoot, cfg.Port, cfg.UseSSL, cfg.LocalOnly, logger) | ||||
| 	webServer := web.NewServer(params.Cfg.WebPath, params.Cfg.ServerRoot, params.Cfg.Port, | ||||
| 		params.Cfg.UseSSL, params.Cfg.LocalOnly, params.Logger) | ||||
| 	// if the adapter is a routed service, register it before the API | ||||
| 	if routedService, ok := wsAdapter.(web.RoutedService); ok { | ||||
| 		webServer.AddRoutes(routedService) | ||||
| 	} | ||||
| 	webServer.AddRoutes(focalboardAPI) | ||||
|  | ||||
| 	settings, err := db.GetSystemSettings() | ||||
| 	settings, err := params.DBStore.GetSystemSettings() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @@ -155,33 +170,34 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, | ||||
| 	telemetryID := settings["TelemetryID"] | ||||
| 	if len(telemetryID) == 0 { | ||||
| 		telemetryID = uuid.New().String() | ||||
| 		if err = db.SetSystemSetting("TelemetryID", uuid.New().String()); err != nil { | ||||
| 		if err = params.DBStore.SetSystemSetting("TelemetryID", uuid.New().String()); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 	} | ||||
| 	telemetryOpts := telemetryOptions{ | ||||
| 		app:         app, | ||||
| 		cfg:         cfg, | ||||
| 		cfg:         params.Cfg, | ||||
| 		telemetryID: telemetryID, | ||||
| 		serverID:    serverID, | ||||
| 		logger:      logger, | ||||
| 		singleUser:  len(singleUserToken) > 0, | ||||
| 		serverID:    params.ServerID, | ||||
| 		logger:      params.Logger, | ||||
| 		singleUser:  len(params.SingleUserToken) > 0, | ||||
| 	} | ||||
| 	telemetryService := initTelemetry(telemetryOpts) | ||||
|  | ||||
| 	server := Server{ | ||||
| 		config:         cfg, | ||||
| 		wsAdapter:      wsAdapter, | ||||
| 		webServer:      webServer, | ||||
| 		store:          db, | ||||
| 		filesBackend:   filesBackend, | ||||
| 		telemetry:      telemetryService, | ||||
| 		metricsServer:  metrics.NewMetricsServer(cfg.PrometheusAddress, metricsService, logger), | ||||
| 		metricsService: metricsService, | ||||
| 		auditService:   auditService, | ||||
| 		logger:         logger, | ||||
| 		localRouter:    localRouter, | ||||
| 		api:            focalboardAPI, | ||||
| 		config:              params.Cfg, | ||||
| 		wsAdapter:           wsAdapter, | ||||
| 		webServer:           webServer, | ||||
| 		store:               params.DBStore, | ||||
| 		filesBackend:        filesBackend, | ||||
| 		telemetry:           telemetryService, | ||||
| 		metricsServer:       metrics.NewMetricsServer(params.Cfg.PrometheusAddress, metricsService, params.Logger), | ||||
| 		metricsService:      metricsService, | ||||
| 		auditService:        auditService, | ||||
| 		notificationService: notificationService, | ||||
| 		logger:              params.Logger, | ||||
| 		localRouter:         localRouter, | ||||
| 		api:                 focalboardAPI, | ||||
| 	} | ||||
|  | ||||
| 	server.initHandlers() | ||||
| @@ -314,6 +330,10 @@ func (s *Server) Shutdown() error { | ||||
| 		s.logger.Warn("Error occurred when shutting down audit service", mlog.Err(err)) | ||||
| 	} | ||||
|  | ||||
| 	if err := s.notificationService.Shutdown(); err != nil { | ||||
| 		s.logger.Warn("Error occurred when shutting down notification service", mlog.Err(err)) | ||||
| 	} | ||||
|  | ||||
| 	defer s.logger.Info("Server.Shutdown") | ||||
|  | ||||
| 	return s.store.Shutdown() | ||||
| @@ -450,3 +470,12 @@ func initTelemetry(opts telemetryOptions) *telemetry.Service { | ||||
| 	}) | ||||
| 	return telemetryService | ||||
| } | ||||
|  | ||||
| func initNotificationService(backends []notify.Backend, logger *mlog.Logger) (*notify.Service, error) { | ||||
| 	loggerBackend := notifylogger.New(logger, mlog.LvlDebug) | ||||
|  | ||||
| 	backends = append(backends, loggerBackend) | ||||
|  | ||||
| 	service, err := notify.New(logger, backends...) | ||||
| 	return service, err | ||||
| } | ||||
|   | ||||
							
								
								
									
										59
									
								
								server/services/notify/notifylogger/logger_backend.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								server/services/notify/notifylogger/logger_backend.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifylogger | ||||
|  | ||||
| import ( | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	backendName = "notifyLogger" | ||||
| ) | ||||
|  | ||||
| type Backend struct { | ||||
| 	logger *mlog.Logger | ||||
| 	level  mlog.Level | ||||
| } | ||||
|  | ||||
| func New(logger *mlog.Logger, level mlog.Level) *Backend { | ||||
| 	return &Backend{ | ||||
| 		logger: logger, | ||||
| 		level:  level, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (b *Backend) Start() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (b *Backend) ShutDown() error { | ||||
| 	_ = b.logger.Flush() | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { | ||||
| 	var board string | ||||
| 	var card string | ||||
|  | ||||
| 	if evt.Board != nil { | ||||
| 		board = evt.Board.Title | ||||
| 	} | ||||
| 	if evt.Card != nil { | ||||
| 		card = evt.Card.Title | ||||
| 	} | ||||
|  | ||||
| 	b.logger.Log(b.level, "Block change event", | ||||
| 		mlog.String("action", string(evt.Action)), | ||||
| 		mlog.String("board", board), | ||||
| 		mlog.String("card", card), | ||||
| 		mlog.String("block_id", evt.BlockChanged.ID), | ||||
| 	) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (b *Backend) Name() string { | ||||
| 	return backendName | ||||
| } | ||||
							
								
								
									
										12
									
								
								server/services/notify/notifymentions/delivery.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/services/notify/notifymentions/delivery.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import "github.com/mattermost/focalboard/server/services/notify" | ||||
|  | ||||
| // Delivery provides an interface for delivering notifications to other systems, such as | ||||
| // MM server or email. | ||||
| type Delivery interface { | ||||
| 	Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error | ||||
| } | ||||
							
								
								
									
										98
									
								
								server/services/notify/notifymentions/extract.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								server/services/notify/notifymentions/extract.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import "strings" | ||||
|  | ||||
| const ( | ||||
| 	defPrefixLines    = 2 | ||||
| 	defPrefixMaxChars = 100 | ||||
| 	defSuffixLines    = 2 | ||||
| 	defSuffixMaxChars = 100 | ||||
| ) | ||||
|  | ||||
| type limits struct { | ||||
| 	prefixLines    int | ||||
| 	prefixMaxChars int | ||||
| 	suffixLines    int | ||||
| 	suffixMaxChars int | ||||
| } | ||||
|  | ||||
| func newLimits() limits { | ||||
| 	return limits{ | ||||
| 		prefixLines:    defPrefixLines, | ||||
| 		prefixMaxChars: defPrefixMaxChars, | ||||
| 		suffixLines:    defSuffixLines, | ||||
| 		suffixMaxChars: defSuffixMaxChars, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // extractText returns all or a subset of the input string, such that | ||||
| // no more than `prefixLines` lines preceding the mention and `suffixLines` | ||||
| // lines after the mention are returned, and no more than approx | ||||
| // prefixMaxChars+suffixMaxChars are returned. | ||||
| func extractText(s string, mention string, limits limits) string { | ||||
| 	if !strings.HasPrefix(mention, "@") { | ||||
| 		mention = "@" + mention | ||||
| 	} | ||||
| 	lines := strings.Split(s, "\n") | ||||
|  | ||||
| 	// find first line with mention | ||||
| 	found := -1 | ||||
| 	for i, l := range lines { | ||||
| 		if strings.Contains(l, mention) { | ||||
| 			found = i | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 	if found == -1 { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	prefix := safeConcat(lines, found-limits.prefixLines, found) | ||||
| 	suffix := safeConcat(lines, found+1, found+limits.suffixLines+1) | ||||
| 	combined := strings.TrimSpace(strings.Join([]string{prefix, lines[found], suffix}, "\n")) | ||||
|  | ||||
| 	// find mention position within | ||||
| 	pos := strings.Index(combined, mention) | ||||
| 	pos = max(pos, 0) | ||||
|  | ||||
| 	return safeSubstr(combined, pos-limits.prefixMaxChars, pos+limits.suffixMaxChars) | ||||
| } | ||||
|  | ||||
| func safeConcat(lines []string, start int, end int) string { | ||||
| 	count := len(lines) | ||||
| 	start = min(max(start, 0), count) | ||||
| 	end = min(max(end, start), count) | ||||
|  | ||||
| 	var sb strings.Builder | ||||
| 	for i := start; i < end; i++ { | ||||
| 		if lines[i] != "" { | ||||
| 			sb.WriteString(lines[i]) | ||||
| 			sb.WriteByte('\n') | ||||
| 		} | ||||
| 	} | ||||
| 	return strings.TrimSpace(sb.String()) | ||||
| } | ||||
|  | ||||
| func safeSubstr(s string, start int, end int) string { | ||||
| 	count := len(s) | ||||
| 	start = min(max(start, 0), count) | ||||
| 	end = min(max(end, start), count) | ||||
| 	return s[start:end] | ||||
| } | ||||
|  | ||||
| func min(a int, b int) int { | ||||
| 	if a < b { | ||||
| 		return a | ||||
| 	} | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| func max(a int, b int) int { | ||||
| 	if a > b { | ||||
| 		return a | ||||
| 	} | ||||
| 	return b | ||||
| } | ||||
							
								
								
									
										115
									
								
								server/services/notify/notifymentions/extract_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								server/services/notify/notifymentions/extract_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	s0 = "Zero is in the mind @billy." | ||||
| 	s1 = "This is line 1." | ||||
| 	s2 = "Line two is right here." | ||||
| 	s3 = "Three is the line I am." | ||||
| 	s4 = "'Four score and seven years...', said @lincoln." | ||||
| 	s5 = "Fast Five was arguably the best F&F film." | ||||
| 	s6 = "Big Hero 6 may have an inflated sense of self." | ||||
| 	s7 = "The seventh sign, @sarah, will be a failed unit test." | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	all       = []string{s0, s1, s2, s3, s4, s5, s6, s7} | ||||
| 	allConcat = strings.Join(all, "\n") | ||||
|  | ||||
| 	extractLimits = limits{ | ||||
| 		prefixLines:    2, | ||||
| 		prefixMaxChars: 100, | ||||
| 		suffixLines:    2, | ||||
| 		suffixMaxChars: 100, | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| func join(s ...string) string { | ||||
| 	return strings.Join(s, "\n") | ||||
| } | ||||
|  | ||||
| func Test_extractText(t *testing.T) { | ||||
| 	type args struct { | ||||
| 		s       string | ||||
| 		mention string | ||||
| 		limits  limits | ||||
| 	} | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		args args | ||||
| 		want string | ||||
| 	}{ | ||||
| 		{name: "good", want: join(s2, s3, s4, s5, s6), args: args{mention: "@lincoln", limits: extractLimits, s: allConcat}}, | ||||
| 		{name: "not found", want: "", args: args{mention: "@bogus", limits: extractLimits, s: allConcat}}, | ||||
| 		{name: "one line", want: join(s4), args: args{mention: "@lincoln", limits: extractLimits, s: s4}}, | ||||
| 		{name: "two lines", want: join(s4, s5), args: args{mention: "@lincoln", limits: extractLimits, s: join(s4, s5)}}, | ||||
| 		{name: "zero lines", want: "", args: args{mention: "@lincoln", limits: extractLimits, s: ""}}, | ||||
| 		{name: "first line mention", want: join(s0, s1, s2), args: args{mention: "@billy", limits: extractLimits, s: allConcat}}, | ||||
| 		{name: "last line mention", want: join(s5[7:], s6, s7), args: args{mention: "@sarah", limits: extractLimits, s: allConcat}}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			if got := extractText(tt.args.s, tt.args.mention, tt.args.limits); got != tt.want { | ||||
| 				t.Errorf("extractText()\ngot:\n%v\nwant:\n%v\n", got, tt.want) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func Test_safeConcat(t *testing.T) { | ||||
| 	type args struct { | ||||
| 		lines []string | ||||
| 		start int | ||||
| 		end   int | ||||
| 	} | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		args args | ||||
| 		want string | ||||
| 	}{ | ||||
| 		{name: "out of range", want: join(s0, s1, s2, s3, s4, s5, s6, s7), args: args{start: -22, end: 99, lines: all}}, | ||||
| 		{name: "2,3", want: join(s2, s3), args: args{start: 2, end: 4, lines: all}}, | ||||
| 		{name: "mismatch", want: "", args: args{start: 4, end: 2, lines: all}}, | ||||
| 		{name: "empty", want: "", args: args{start: 2, end: 4, lines: []string{}}}, | ||||
| 		{name: "nil", want: "", args: args{start: 2, end: 4, lines: nil}}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			if got := safeConcat(tt.args.lines, tt.args.start, tt.args.end); got != tt.want { | ||||
| 				t.Errorf("safeConcat() = [%v], want [%v]", got, tt.want) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func Test_safeSubstr(t *testing.T) { | ||||
| 	type args struct { | ||||
| 		s     string | ||||
| 		start int | ||||
| 		end   int | ||||
| 	} | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		args args | ||||
| 		want string | ||||
| 	}{ | ||||
| 		{name: "good", want: "is line", args: args{start: 33, end: 40, s: join(s0, s1, s2)}}, | ||||
| 		{name: "out of range", want: allConcat, args: args{start: -10, end: 1000, s: allConcat}}, | ||||
| 		{name: "mismatch", want: "", args: args{start: 33, end: 26, s: allConcat}}, | ||||
| 		{name: "empty", want: "", args: args{start: 2, end: 4, s: ""}}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			if got := safeSubstr(tt.args.s, tt.args.start, tt.args.end); got != tt.want { | ||||
| 				t.Errorf("safeSubstr()\ngot:\n[%v]\nwant:\n[%v]\n", got, tt.want) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										34
									
								
								server/services/notify/notifymentions/mentions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								server/services/notify/notifymentions/mentions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import ( | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
|  | ||||
| 	mm_model "github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_:]*`) | ||||
|  | ||||
| // extractMentions extracts any mentions in the specified block and returns | ||||
| // a slice of usernames. | ||||
| func extractMentions(block *model.Block) map[string]struct{} { | ||||
| 	mentions := make(map[string]struct{}) | ||||
| 	if block == nil || !strings.Contains(block.Title, "@") { | ||||
| 		return mentions | ||||
| 	} | ||||
|  | ||||
| 	str := block.Title | ||||
|  | ||||
| 	for _, match := range atMentionRegexp.FindAllString(str, -1) { | ||||
| 		name := mm_model.NormalizeUsername(match[1:]) | ||||
| 		if mm_model.IsValidUsernameAllowRemote(name) { | ||||
| 			mentions[name] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
| 	return mentions | ||||
| } | ||||
							
								
								
									
										79
									
								
								server/services/notify/notifymentions/mentions_backend.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								server/services/notify/notifymentions/mentions_backend.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
| 	"github.com/wiggin77/merror" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	backendName = "notifyMentions" | ||||
| ) | ||||
|  | ||||
| type Backend struct { | ||||
| 	delivery Delivery | ||||
| 	logger   *mlog.Logger | ||||
| } | ||||
|  | ||||
| func New(delivery Delivery, logger *mlog.Logger) *Backend { | ||||
| 	return &Backend{ | ||||
| 		delivery: delivery, | ||||
| 		logger:   logger, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (b *Backend) Start() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (b *Backend) ShutDown() error { | ||||
| 	_ = b.logger.Flush() | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (b *Backend) Name() string { | ||||
| 	return backendName | ||||
| } | ||||
|  | ||||
| func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error { | ||||
| 	if evt.Board == nil || evt.Card == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if evt.Action == notify.Delete { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if evt.BlockChanged.Type != "text" && evt.BlockChanged.Type != "comment" { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	mentions := extractMentions(evt.BlockChanged) | ||||
| 	if len(mentions) == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	oldMentions := extractMentions(evt.BlockOld) | ||||
| 	merr := merror.New() | ||||
|  | ||||
| 	for username := range mentions { | ||||
| 		if _, exists := oldMentions[username]; exists { | ||||
| 			// the mention already existed; no need to notify again | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		extract := extractText(evt.BlockChanged.Title, username, newLimits()) | ||||
|  | ||||
| 		err := b.delivery.Deliver(username, extract, evt) | ||||
| 		if err != nil { | ||||
| 			merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err)) | ||||
| 		} | ||||
| 	} | ||||
| 	return merr.ErrorOrNil() | ||||
| } | ||||
							
								
								
									
										52
									
								
								server/services/notify/notifymentions/mentions_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								server/services/notify/notifymentions/mentions_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notifymentions | ||||
|  | ||||
| import ( | ||||
| 	"reflect" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
|  | ||||
| 	mm_model "github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| func Test_extractMentions(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name  string | ||||
| 		block *model.Block | ||||
| 		want  map[string]struct{} | ||||
| 	}{ | ||||
| 		{name: "empty", block: makeBlock(""), want: makeMap()}, | ||||
| 		{name: "zero mentions", block: makeBlock("This is some text."), want: makeMap()}, | ||||
| 		{name: "one mention", block: makeBlock("Hello @user1"), want: makeMap("user1")}, | ||||
| 		{name: "multiple mentions", block: makeBlock("Hello @user1, @user2 and @user3"), want: makeMap("user1", "user2", "user3")}, | ||||
| 		{name: "include period", block: makeBlock("Hello @user1."), want: makeMap("user1.")}, | ||||
| 		{name: "include underscore", block: makeBlock("Hello @user1_"), want: makeMap("user1_")}, | ||||
| 		{name: "don't include comma", block: makeBlock("Hello @user1,"), want: makeMap("user1")}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			if got := extractMentions(tt.block); !reflect.DeepEqual(got, tt.want) { | ||||
| 				t.Errorf("extractMentions() = %v, want %v", got, tt.want) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func makeBlock(text string) *model.Block { | ||||
| 	return &model.Block{ | ||||
| 		ID:    mm_model.NewId(), | ||||
| 		Type:  "comment", | ||||
| 		Title: text, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func makeMap(mentions ...string) map[string]struct{} { | ||||
| 	m := make(map[string]struct{}) | ||||
| 	for _, mention := range mentions { | ||||
| 		m[mention] = struct{}{} | ||||
| 	} | ||||
| 	return m | ||||
| } | ||||
							
								
								
									
										28
									
								
								server/services/notify/plugindelivery/message.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								server/services/notify/plugindelivery/message.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package plugindelivery | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	// TODO: localize these when i18n is available. | ||||
| 	defCommentTemplate     = "@%s mentioned you in a comment on the card [%s](%s)\n> %s" | ||||
| 	defDescriptionTemplate = "@%s mentioned you in the card [%s](%s)\n> %s" | ||||
| ) | ||||
|  | ||||
| func formatMessage(author string, extract string, card string, link string, block *model.Block) string { | ||||
| 	template := defDescriptionTemplate | ||||
| 	if block.Type == "comment" { | ||||
| 		template = defCommentTemplate | ||||
| 	} | ||||
| 	return fmt.Sprintf(template, author, card, link, extract) | ||||
| } | ||||
|  | ||||
| func makeLink(serverRoot string, workspace string, board string, card string) string { | ||||
| 	return fmt.Sprintf("%s/workspace/%s/%s/0/%s/", serverRoot, workspace, board, card) | ||||
| } | ||||
							
								
								
									
										72
									
								
								server/services/notify/plugindelivery/plugin_delivery.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								server/services/notify/plugindelivery/plugin_delivery.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package plugindelivery | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/services/notify" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| type PluginAPI interface { | ||||
| 	// GetDirectChannel gets a direct message channel. | ||||
| 	// If the channel does not exist it will create it. | ||||
| 	GetDirectChannel(userID1, userID2 string) (*model.Channel, error) | ||||
|  | ||||
| 	// CreatePost creates a post. | ||||
| 	CreatePost(post *model.Post) error | ||||
|  | ||||
| 	// GetUserByIS gets a user by their ID. | ||||
| 	GetUserByID(userID string) (*model.User, error) | ||||
|  | ||||
| 	// GetUserByUsername gets a user by their username. | ||||
| 	GetUserByUsername(name string) (*model.User, error) | ||||
| } | ||||
|  | ||||
| // PluginDelivery provides ability to send notifications to direct message channels via Mattermost plugin API. | ||||
| type PluginDelivery struct { | ||||
| 	botID      string | ||||
| 	serverRoot string | ||||
| 	api        PluginAPI | ||||
| } | ||||
|  | ||||
| func New(botID string, serverRoot string, api PluginAPI) *PluginDelivery { | ||||
| 	return &PluginDelivery{ | ||||
| 		botID:      botID, | ||||
| 		serverRoot: serverRoot, | ||||
| 		api:        api, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (pd *PluginDelivery) Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error { | ||||
| 	user, err := userFromUsername(pd.api, mentionUsername) | ||||
| 	if err != nil { | ||||
| 		if isErrNotFound(err) { | ||||
| 			// not really an error; could just be someone typed "@sometext" | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			return fmt.Errorf("cannot lookup mentioned user: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	author, err := pd.api.GetUserByID(evt.UserID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cannot find user: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	channel, err := pd.api.GetDirectChannel(user.Id, pd.botID) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("cannot get direct channel: %w", err) | ||||
| 	} | ||||
| 	link := makeLink(pd.serverRoot, evt.Workspace, evt.Board.ID, evt.Card.ID) | ||||
|  | ||||
| 	post := &model.Post{ | ||||
| 		UserId:    pd.botID, | ||||
| 		ChannelId: channel.Id, | ||||
| 		Message:   formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged), | ||||
| 	} | ||||
| 	return pd.api.CreatePost(post) | ||||
| } | ||||
							
								
								
									
										60
									
								
								server/services/notify/plugindelivery/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								server/services/notify/plugindelivery/user.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package plugindelivery | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
|  | ||||
| 	mm_model "github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	usernameSpecialChars = ".-_ " | ||||
| ) | ||||
|  | ||||
| func userFromUsername(api PluginAPI, username string) (*mm_model.User, error) { | ||||
| 	user, err := api.GetUserByUsername(username) | ||||
| 	if err == nil { | ||||
| 		return user, nil | ||||
| 	} | ||||
|  | ||||
| 	// only continue if the error is `ErrNotFound` | ||||
| 	if !isErrNotFound(err) { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// check for usernames in substrings without trailing punctuation | ||||
| 	trimmed, ok := trimUsernameSpecialChar(username) | ||||
| 	for ; ok; trimmed, ok = trimUsernameSpecialChar(trimmed) { | ||||
| 		userFromTrimmed, err2 := api.GetUserByUsername(trimmed) | ||||
| 		if err2 != nil && !isErrNotFound(err2) { | ||||
| 			return nil, err2 | ||||
| 		} | ||||
|  | ||||
| 		if err2 == nil { | ||||
| 			return userFromTrimmed, nil | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, err | ||||
| } | ||||
|  | ||||
| // trimUsernameSpecialChar tries to remove the last character from word if it | ||||
| // is a special character for usernames (dot, dash or underscore). If not, it | ||||
| // returns the same string. | ||||
| func trimUsernameSpecialChar(word string) (string, bool) { | ||||
| 	len := len(word) | ||||
|  | ||||
| 	if len > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (len-1) { | ||||
| 		return word[:len-1], true | ||||
| 	} | ||||
|  | ||||
| 	return word, false | ||||
| } | ||||
|  | ||||
| // isErrNotFound returns true if the error is a plugin.ErrNotFound. The pluginAPI converts | ||||
| // AppError to the plugin.ErrNotFound var. | ||||
| // TODO: add a `IsErrNotFound` method to the plugin API. | ||||
| func isErrNotFound(err error) bool { | ||||
| 	return err != nil && err.Error() == "not found" | ||||
| } | ||||
							
								
								
									
										105
									
								
								server/services/notify/plugindelivery/user_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								server/services/notify/plugindelivery/user_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package plugindelivery | ||||
|  | ||||
| import ( | ||||
| 	"reflect" | ||||
| 	"testing" | ||||
|  | ||||
| 	mm_model "github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	user1 = &mm_model.User{ | ||||
| 		Id:       mm_model.NewId(), | ||||
| 		Username: "dlauder", | ||||
| 	} | ||||
| 	user2 = &mm_model.User{ | ||||
| 		Id:       mm_model.NewId(), | ||||
| 		Username: "steve.mqueen", | ||||
| 	} | ||||
| 	user3 = &mm_model.User{ | ||||
| 		Id:       mm_model.NewId(), | ||||
| 		Username: "bart_", | ||||
| 	} | ||||
| 	user4 = &mm_model.User{ | ||||
| 		Id:       mm_model.NewId(), | ||||
| 		Username: "missing_", | ||||
| 	} | ||||
|  | ||||
| 	mockUsers = map[string]*mm_model.User{ | ||||
| 		"dlauder":      user1, | ||||
| 		"steve.mqueen": user2, | ||||
| 		"bart_":        user3, | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| func Test_userFromUsername(t *testing.T) { | ||||
| 	delivery := newPlugAPIMock(mockUsers) | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name    string | ||||
| 		uname   string | ||||
| 		want    *mm_model.User | ||||
| 		wantErr bool | ||||
| 	}{ | ||||
| 		{name: "user1", uname: user1.Username, want: user1, wantErr: false}, | ||||
| 		{name: "user1 with period", uname: user1.Username + ".", want: user1, wantErr: false}, | ||||
| 		{name: "user1 with period plus more", uname: user1.Username + ". ", want: user1, wantErr: false}, | ||||
| 		{name: "user2 with periods", uname: user2.Username + "...", want: user2, wantErr: false}, | ||||
| 		{name: "user2 with underscore", uname: user2.Username + "_", want: user2, wantErr: false}, | ||||
| 		{name: "user2 with hyphen plus more", uname: user2.Username + "- ", want: user2, wantErr: false}, | ||||
| 		{name: "user2 with hyphen plus all", uname: user2.Username + ".-_ ", want: user2, wantErr: false}, | ||||
| 		{name: "user3 with underscore", uname: user3.Username + "_", want: user3, wantErr: false}, | ||||
| 		{name: "user4 missing", uname: user4.Username, want: nil, wantErr: true}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			got, err := userFromUsername(delivery, tt.uname) | ||||
| 			if (err != nil) != tt.wantErr { | ||||
| 				t.Errorf("userFromUsername() error = %v, wantErr %v", err, tt.wantErr) | ||||
| 				return | ||||
| 			} | ||||
| 			if !reflect.DeepEqual(got, tt.want) { | ||||
| 				t.Errorf("userFromUsername() = %v, want %v", got, tt.want) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type pluginAPIMock struct { | ||||
| 	users map[string]*mm_model.User | ||||
| } | ||||
|  | ||||
| func newPlugAPIMock(users map[string]*mm_model.User) pluginAPIMock { | ||||
| 	return pluginAPIMock{ | ||||
| 		users: users, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (m pluginAPIMock) GetUserByUsername(name string) (*mm_model.User, error) { | ||||
| 	user, ok := m.users[name] | ||||
| 	if !ok { | ||||
| 		return nil, ErrNotFound{} | ||||
| 	} | ||||
| 	return user, nil | ||||
| } | ||||
|  | ||||
| func (m pluginAPIMock) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) { | ||||
| 	return nil, nil | ||||
| } | ||||
|  | ||||
| func (m pluginAPIMock) CreatePost(post *mm_model.Post) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (m pluginAPIMock) GetUserByID(userID string) (*mm_model.User, error) { | ||||
| 	return nil, nil | ||||
| } | ||||
|  | ||||
| type ErrNotFound struct{} | ||||
|  | ||||
| func (e ErrNotFound) Error() string { | ||||
| 	return "not found" | ||||
| } | ||||
							
								
								
									
										108
									
								
								server/services/notify/service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								server/services/notify/service.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| package notify | ||||
|  | ||||
| import ( | ||||
| 	"sync" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
| 	"github.com/wiggin77/merror" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
|  | ||||
| type Action string | ||||
|  | ||||
| const ( | ||||
| 	Add    Action = "add" | ||||
| 	Update Action = "update" | ||||
| 	Delete Action = "delete" | ||||
| ) | ||||
|  | ||||
| type BlockChangeEvent struct { | ||||
| 	Action       Action | ||||
| 	Workspace    string | ||||
| 	Board        *model.Block | ||||
| 	Card         *model.Block | ||||
| 	BlockChanged *model.Block | ||||
| 	BlockOld     *model.Block | ||||
| 	UserID       string | ||||
| } | ||||
|  | ||||
| // Backend provides an interface for sending notifications. | ||||
| type Backend interface { | ||||
| 	Start() error | ||||
| 	ShutDown() error | ||||
| 	BlockChanged(evt BlockChangeEvent) error | ||||
| 	Name() string | ||||
| } | ||||
|  | ||||
| // Service is a service that sends notifications based on block activity using one or more backends. | ||||
| type Service struct { | ||||
| 	mux      sync.RWMutex | ||||
| 	backends []Backend | ||||
| 	logger   *mlog.Logger | ||||
| } | ||||
|  | ||||
| // New creates a notification service with one or more Backends capable of sending notifications. | ||||
| func New(logger *mlog.Logger, backends ...Backend) (*Service, error) { | ||||
| 	notify := &Service{ | ||||
| 		backends: make([]Backend, 0, len(backends)), | ||||
| 		logger:   logger, | ||||
| 	} | ||||
|  | ||||
| 	merr := merror.New() | ||||
| 	for _, backend := range backends { | ||||
| 		if err := notify.AddBackend(backend); err != nil { | ||||
| 			merr.Append(err) | ||||
| 		} else { | ||||
| 			logger.Info("Initialized notification backend", mlog.String("name", backend.Name())) | ||||
| 		} | ||||
| 	} | ||||
| 	return notify, merr.ErrorOrNil() | ||||
| } | ||||
|  | ||||
| // AddBackend adds a backend to the list that will be informed of any block changes. | ||||
| func (s *Service) AddBackend(backend Backend) error { | ||||
| 	if err := backend.Start(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	s.mux.Lock() | ||||
| 	defer s.mux.Unlock() | ||||
| 	s.backends = append(s.backends, backend) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Shutdown calls shutdown for all backends. | ||||
| func (s *Service) Shutdown() error { | ||||
| 	s.mux.Lock() | ||||
| 	defer s.mux.Unlock() | ||||
|  | ||||
| 	merr := merror.New() | ||||
| 	for _, backend := range s.backends { | ||||
| 		if err := backend.ShutDown(); err != nil { | ||||
| 			merr.Append(err) | ||||
| 		} | ||||
| 	} | ||||
| 	s.backends = nil | ||||
| 	return merr.ErrorOrNil() | ||||
| } | ||||
|  | ||||
| // BlockChanged should be called whenever a block is added/updated/deleted. | ||||
| // All backends are informed of the event. | ||||
| func (s *Service) BlockChanged(evt BlockChangeEvent) { | ||||
| 	s.mux.RLock() | ||||
| 	defer s.mux.RUnlock() | ||||
|  | ||||
| 	for _, backend := range s.backends { | ||||
| 		if err := backend.BlockChanged(evt); err != nil { | ||||
| 			s.logger.Error("Error delivering notification", | ||||
| 				mlog.String("backend", backend.Name()), | ||||
| 				mlog.String("action", string(evt.Action)), | ||||
| 				mlog.String("block_id", evt.BlockChanged.ID), | ||||
| 				mlog.Err(err), | ||||
| 			) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
		Reference in New Issue
	
	Block a user