From 6176bcff6977bda71f4fde10a52dde6d7d7ceb9a Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Mon, 27 Nov 2017 17:23:35 -0500 Subject: PLT-8131 (part2) Add plugin key value store support (#7902) * Add plugin key value store support * Add localization strings * Updates per feedback --- app/plugin.go | 472 +++++++++++++++ app/plugin_api.go | 260 +++++++++ app/plugin_test.go | 35 ++ app/plugins.go | 649 --------------------- i18n/en.json | 28 + model/plugin_key_value.go | 26 + model/plugin_key_value_test.go | 22 + plugin/api.go | 16 +- plugin/plugintest/api.go | 38 ++ plugin/rpcplugin/api.go | 85 ++- plugin/rpcplugin/api_test.go | 18 +- store/layered_store.go | 4 + store/sqlstore/plugin_store.go | 92 +++ store/sqlstore/plugin_store_test.go | 14 + store/sqlstore/store.go | 1 + store/sqlstore/supplier.go | 7 + store/store.go | 7 + store/storetest/mocks/LayeredStoreDatabaseLayer.go | 16 + store/storetest/mocks/PluginStore.go | 62 ++ store/storetest/mocks/SqlStore.go | 30 + store/storetest/mocks/Store.go | 16 + store/storetest/plugin_store.go | 69 +++ store/storetest/store.go | 3 + 23 files changed, 1314 insertions(+), 656 deletions(-) create mode 100644 app/plugin.go create mode 100644 app/plugin_api.go create mode 100644 app/plugin_test.go delete mode 100644 app/plugins.go create mode 100644 model/plugin_key_value.go create mode 100644 model/plugin_key_value_test.go create mode 100644 store/sqlstore/plugin_store.go create mode 100644 store/sqlstore/plugin_store_test.go create mode 100644 store/storetest/mocks/PluginStore.go create mode 100644 store/storetest/plugin_store.go diff --git a/app/plugin.go b/app/plugin.go new file mode 100644 index 000000000..4645eb9fb --- /dev/null +++ b/app/plugin.go @@ -0,0 +1,472 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + + l4g "github.com/alecthomas/log4go" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/utils" + + builtinplugin "github.com/mattermost/mattermost-server/app/plugin" + "github.com/mattermost/mattermost-server/app/plugin/jira" + "github.com/mattermost/mattermost-server/app/plugin/ldapextras" + + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/pluginenv" +) + +func (a *App) initBuiltInPlugins() { + plugins := map[string]builtinplugin.Plugin{ + "jira": &jira.Plugin{}, + "ldapextras": &ldapextras.Plugin{}, + } + for id, p := range plugins { + l4g.Info("Initializing plugin: " + id) + api := &BuiltInPluginAPI{ + id: id, + router: a.Srv.Router.PathPrefix("/plugins/" + id).Subrouter(), + app: a, + } + p.Initialize(api) + } + utils.AddConfigListener(func(before, after *model.Config) { + for _, p := range plugins { + p.OnConfigurationChange() + } + }) + for _, p := range plugins { + p.OnConfigurationChange() + } +} + +// ActivatePlugins will activate any plugins enabled in the config +// and deactivate all other plugins. +func (a *App) ActivatePlugins() { + if a.PluginEnv == nil { + l4g.Error("plugin env not initialized") + return + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + l4g.Error("failed to activate plugins: " + err.Error()) + return + } + + for _, plugin := range plugins { + id := plugin.Manifest.Id + + pluginState := &model.PluginState{Enable: false} + if state, ok := a.Config().PluginSettings.PluginStates[id]; ok { + pluginState = state + } + + active := a.PluginEnv.IsPluginActive(id) + + if pluginState.Enable && !active { + if err := a.PluginEnv.ActivatePlugin(id); err != nil { + l4g.Error(err.Error()) + continue + } + + if plugin.Manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_ACTIVATED, "", "", "", nil) + message.Add("manifest", plugin.Manifest.ClientManifest()) + a.Publish(message) + } + + l4g.Info("Activated %v plugin", id) + } else if !pluginState.Enable && active { + if err := a.PluginEnv.DeactivatePlugin(id); err != nil { + l4g.Error(err.Error()) + continue + } + + if plugin.Manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", plugin.Manifest.ClientManifest()) + a.Publish(message) + } + + l4g.Info("Deactivated %v plugin", id) + } + } +} + +// InstallPlugin unpacks and installs a plugin but does not activate it. +func (a *App) InstallPlugin(pluginFile io.Reader) (*model.Manifest, *model.AppError) { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return nil, model.NewAppError("InstallPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + tmpDir, err := ioutil.TempDir("", "plugintmp") + if err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.filesystem.app_error", nil, err.Error(), http.StatusInternalServerError) + } + defer os.RemoveAll(tmpDir) + + if err := utils.ExtractTarGz(pluginFile, tmpDir); err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.extract.app_error", nil, err.Error(), http.StatusBadRequest) + } + + tmpPluginDir := tmpDir + dir, err := ioutil.ReadDir(tmpDir) + if err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.filesystem.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if len(dir) == 1 && dir[0].IsDir() { + tmpPluginDir = filepath.Join(tmpPluginDir, dir[0].Name()) + } + + manifest, _, err := model.FindManifest(tmpPluginDir) + if err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.manifest.app_error", nil, err.Error(), http.StatusBadRequest) + } + + bundles, err := a.PluginEnv.Plugins() + if err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.install.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, bundle := range bundles { + if bundle.Manifest.Id == manifest.Id { + return nil, model.NewAppError("InstallPlugin", "app.plugin.install_id.app_error", nil, "", http.StatusBadRequest) + } + } + + err = utils.CopyDir(tmpPluginDir, filepath.Join(a.PluginEnv.SearchPath(), manifest.Id)) + if err != nil { + return nil, model.NewAppError("InstallPlugin", "app.plugin.mvdir.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // Should add manifest validation and error handling here + + return manifest, nil +} + +func (a *App) GetPluginManifests() (*model.PluginsResponse, *model.AppError) { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return nil, model.NewAppError("GetPluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return nil, model.NewAppError("GetPluginManifests", "app.plugin.get_plugins.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + resp := &model.PluginsResponse{Active: []*model.Manifest{}, Inactive: []*model.Manifest{}} + for _, plugin := range plugins { + if a.PluginEnv.IsPluginActive(plugin.Manifest.Id) { + resp.Active = append(resp.Active, plugin.Manifest) + } else { + resp.Inactive = append(resp.Inactive, plugin.Manifest) + } + } + + return resp, nil +} + +func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return nil, model.NewAppError("GetActivePluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins := a.PluginEnv.ActivePlugins() + + manifests := make([]*model.Manifest, len(plugins)) + for i, plugin := range plugins { + manifests[i] = plugin.Manifest + } + + return manifests, nil +} + +func (a *App) RemovePlugin(id string) *model.AppError { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } + + var manifest *model.Manifest + for _, p := range plugins { + if p.Manifest.Id == id { + manifest = p.Manifest + break + } + } + + if manifest == nil { + return model.NewAppError("RemovePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + if a.PluginEnv.IsPluginActive(id) { + err := a.PluginEnv.DeactivatePlugin(id) + if err != nil { + return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } + + if manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", manifest.ClientManifest()) + a.Publish(message) + } + } + + err = os.RemoveAll(filepath.Join(a.PluginEnv.SearchPath(), id)) + if err != nil { + return model.NewAppError("RemovePlugin", "app.plugin.remove.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +// EnablePlugin will set the config for an installed plugin to enabled, triggering activation if inactive. +func (a *App) EnablePlugin(id string) *model.AppError { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + var manifest *model.Manifest + for _, p := range plugins { + if p.Manifest.Id == id { + manifest = p.Manifest + break + } + } + + if manifest == nil { + return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + a.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: true} + }) + + if err := a.SaveConfig(a.Config(), true); err != nil { + return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +// DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. +func (a *App) DisablePlugin(id string) *model.AppError { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + var manifest *model.Manifest + for _, p := range plugins { + if p.Manifest.Id == id { + manifest = p.Manifest + break + } + } + + if manifest == nil { + return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + a.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false} + }) + + if err := a.SaveConfig(a.Config(), true); err != nil { + return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (a *App) InitPlugins(pluginPath, webappPath string) { + if !*a.Config().PluginSettings.Enable { + return + } + + if a.PluginEnv != nil { + return + } + + l4g.Info("Starting up plugins") + + err := os.Mkdir(pluginPath, 0744) + if err != nil && !os.IsExist(err) { + l4g.Error("failed to start up plugins: " + err.Error()) + return + } + + err = os.Mkdir(webappPath, 0744) + if err != nil && !os.IsExist(err) { + l4g.Error("failed to start up plugins: " + err.Error()) + return + } + + a.PluginEnv, err = pluginenv.New( + pluginenv.SearchPath(pluginPath), + pluginenv.WebappPath(webappPath), + pluginenv.APIProvider(func(m *model.Manifest) (plugin.API, error) { + return &PluginAPI{ + id: m.Id, + app: a, + keyValueStore: &PluginKeyValueStore{ + id: m.Id, + app: a, + }, + }, nil + }), + ) + + if err != nil { + l4g.Error("failed to start up plugins: " + err.Error()) + return + } + + utils.RemoveConfigListener(a.PluginConfigListenerId) + a.PluginConfigListenerId = utils.AddConfigListener(func(prevCfg, cfg *model.Config) { + if a.PluginEnv == nil { + return + } + + if *prevCfg.PluginSettings.Enable && *cfg.PluginSettings.Enable { + a.ActivatePlugins() + } + + for _, err := range a.PluginEnv.Hooks().OnConfigurationChange() { + l4g.Error(err.Error()) + } + }) + + a.ActivatePlugins() +} + +func (a *App) ServePluginRequest(w http.ResponseWriter, r *http.Request) { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + err := model.NewAppError("ServePluginRequest", "app.plugin.disabled.app_error", nil, "Enable plugins to serve plugin requests", http.StatusNotImplemented) + err.Translate(utils.T) + l4g.Error(err.Error()) + w.WriteHeader(err.StatusCode) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(err.ToJson())) + return + } + + token := "" + + authHeader := r.Header.Get(model.HEADER_AUTH) + if strings.HasPrefix(strings.ToUpper(authHeader), model.HEADER_BEARER+":") { + token = authHeader[len(model.HEADER_BEARER)+1:] + } else if strings.HasPrefix(strings.ToLower(authHeader), model.HEADER_TOKEN+":") { + token = authHeader[len(model.HEADER_TOKEN)+1:] + } else if cookie, _ := r.Cookie(model.SESSION_COOKIE_TOKEN); cookie != nil && (r.Method == "GET" || r.Header.Get(model.HEADER_REQUESTED_WITH) == model.HEADER_REQUESTED_WITH_XML) { + token = cookie.Value + } else { + token = r.URL.Query().Get("access_token") + } + + r.Header.Del("Mattermost-User-Id") + if token != "" { + if session, err := a.GetSession(token); err != nil { + r.Header.Set("Mattermost-User-Id", session.UserId) + } + } + + cookies := r.Cookies() + r.Header.Del("Cookie") + for _, c := range cookies { + if c.Name != model.SESSION_COOKIE_TOKEN { + r.AddCookie(c) + } + } + r.Header.Del(model.HEADER_AUTH) + r.Header.Del("Referer") + + newQuery := r.URL.Query() + newQuery.Del("access_token") + r.URL.RawQuery = newQuery.Encode() + + params := mux.Vars(r) + a.PluginEnv.Hooks().ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "plugin_id", params["plugin_id"]))) +} + +func (a *App) ShutDownPlugins() { + if a.PluginEnv == nil { + return + } + + l4g.Info("Shutting down plugins") + + for _, err := range a.PluginEnv.Shutdown() { + l4g.Error(err.Error()) + } + utils.RemoveConfigListener(a.PluginConfigListenerId) + a.PluginConfigListenerId = "" + a.PluginEnv = nil +} + +func (a *App) SetPluginKey(pluginId string, key string, value []byte) *model.AppError { + kv := &model.PluginKeyValue{ + PluginId: pluginId, + Key: key, + Value: value, + } + + result := <-a.Srv.Store.Plugin().SaveOrUpdate(kv) + + if result.Err != nil { + l4g.Error(result.Err.Error()) + } + + return result.Err +} + +func (a *App) GetPluginKey(pluginId string, key string) ([]byte, *model.AppError) { + result := <-a.Srv.Store.Plugin().Get(pluginId, key) + + if result.Err != nil { + if result.Err.StatusCode == http.StatusNotFound { + return nil, nil + } + l4g.Error(result.Err.Error()) + return nil, result.Err + } + + kv := result.Data.(*model.PluginKeyValue) + + return kv.Value, nil +} + +func (a *App) DeletePluginKey(pluginId string, key string) *model.AppError { + result := <-a.Srv.Store.Plugin().Delete(pluginId, key) + + if result.Err != nil { + l4g.Error(result.Err.Error()) + } + + return result.Err +} diff --git a/app/plugin_api.go b/app/plugin_api.go new file mode 100644 index 000000000..bfe86453f --- /dev/null +++ b/app/plugin_api.go @@ -0,0 +1,260 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/utils" + + "github.com/mattermost/mattermost-server/plugin" +) + +type PluginAPI struct { + id string + app *App + keyValueStore *PluginKeyValueStore +} + +type PluginKeyValueStore struct { + id string + app *App +} + +func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error { + if b, err := json.Marshal(api.app.Config().PluginSettings.Plugins[api.id]); err != nil { + return err + } else { + return json.Unmarshal(b, dest) + } +} + +func (api *PluginAPI) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { + return api.app.CreateTeam(team) +} + +func (api *PluginAPI) DeleteTeam(teamId string) *model.AppError { + return api.app.SoftDeleteTeam(teamId) +} + +func (api *PluginAPI) GetTeam(teamId string) (*model.Team, *model.AppError) { + return api.app.GetTeam(teamId) +} + +func (api *PluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) { + return api.app.GetTeamByName(name) +} + +func (api *PluginAPI) UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { + return api.app.UpdateTeam(team) +} + +func (api *PluginAPI) CreateUser(user *model.User) (*model.User, *model.AppError) { + return api.app.CreateUser(user) +} + +func (api *PluginAPI) DeleteUser(userId string) *model.AppError { + user, err := api.app.GetUser(userId) + if err != nil { + return err + } + _, err = api.app.UpdateActive(user, false) + return err +} + +func (api *PluginAPI) GetUser(userId string) (*model.User, *model.AppError) { + return api.app.GetUser(userId) +} + +func (api *PluginAPI) GetUserByEmail(email string) (*model.User, *model.AppError) { + return api.app.GetUserByEmail(email) +} + +func (api *PluginAPI) GetUserByUsername(name string) (*model.User, *model.AppError) { + return api.app.GetUserByUsername(name) +} + +func (api *PluginAPI) UpdateUser(user *model.User) (*model.User, *model.AppError) { + return api.app.UpdateUser(user, true) +} + +func (api *PluginAPI) CreateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { + return api.app.CreateChannel(channel, false) +} + +func (api *PluginAPI) DeleteChannel(channelId string) *model.AppError { + channel, err := api.app.GetChannel(channelId) + if err != nil { + return err + } + return api.app.DeleteChannel(channel, "") +} + +func (api *PluginAPI) GetChannel(channelId string) (*model.Channel, *model.AppError) { + return api.app.GetChannel(channelId) +} + +func (api *PluginAPI) GetChannelByName(name, teamId string) (*model.Channel, *model.AppError) { + return api.app.GetChannelByName(name, teamId) +} + +func (api *PluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { + return api.app.GetDirectChannel(userId1, userId2) +} + +func (api *PluginAPI) GetGroupChannel(userIds []string) (*model.Channel, *model.AppError) { + return api.app.CreateGroupChannel(userIds, "") +} + +func (api *PluginAPI) UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { + return api.app.UpdateChannel(channel) +} + +func (api *PluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { + return api.app.CreatePostMissingChannel(post, true) +} + +func (api *PluginAPI) DeletePost(postId string) *model.AppError { + _, err := api.app.DeletePost(postId) + return err +} + +func (api *PluginAPI) GetPost(postId string) (*model.Post, *model.AppError) { + return api.app.GetSinglePost(postId) +} + +func (api *PluginAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError) { + return api.app.UpdatePost(post, false) +} + +func (api *PluginAPI) KeyValueStore() plugin.KeyValueStore { + return api.keyValueStore +} + +func (s *PluginKeyValueStore) Set(key string, value []byte) *model.AppError { + return s.app.SetPluginKey(s.id, key, value) +} + +func (s *PluginKeyValueStore) Get(key string) ([]byte, *model.AppError) { + return s.app.GetPluginKey(s.id, key) +} + +func (s *PluginKeyValueStore) Delete(key string) *model.AppError { + return s.app.DeletePluginKey(s.id, key) +} + +type BuiltInPluginAPI struct { + id string + router *mux.Router + app *App +} + +func (api *BuiltInPluginAPI) LoadPluginConfiguration(dest interface{}) error { + if b, err := json.Marshal(api.app.Config().PluginSettings.Plugins[api.id]); err != nil { + return err + } else { + return json.Unmarshal(b, dest) + } +} + +func (api *BuiltInPluginAPI) PluginRouter() *mux.Router { + return api.router +} + +func (api *BuiltInPluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) { + return api.app.GetTeamByName(name) +} + +func (api *BuiltInPluginAPI) GetUserByName(name string) (*model.User, *model.AppError) { + return api.app.GetUserByUsername(name) +} + +func (api *BuiltInPluginAPI) GetChannelByName(teamId, name string) (*model.Channel, *model.AppError) { + return api.app.GetChannelByName(name, teamId) +} + +func (api *BuiltInPluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { + return api.app.GetDirectChannel(userId1, userId2) +} + +func (api *BuiltInPluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { + return api.app.CreatePostMissingChannel(post, true) +} + +func (api *BuiltInPluginAPI) GetLdapUserAttributes(userId string, attributes []string) (map[string]string, *model.AppError) { + if api.app.Ldap == nil { + return nil, model.NewAppError("GetLdapUserAttributes", "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + user, err := api.app.GetUser(userId) + if err != nil { + return nil, err + } + + if user.AuthData == nil { + return map[string]string{}, nil + } + + return api.app.Ldap.GetUserAttributes(*user.AuthData, attributes) +} + +func (api *BuiltInPluginAPI) GetSessionFromRequest(r *http.Request) (*model.Session, *model.AppError) { + token := "" + isTokenFromQueryString := false + + // Attempt to parse token out of the header + authHeader := r.Header.Get(model.HEADER_AUTH) + if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == model.HEADER_BEARER { + // Default session token + token = authHeader[7:] + + } else if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == model.HEADER_TOKEN { + // OAuth token + token = authHeader[6:] + } + + // Attempt to parse the token from the cookie + if len(token) == 0 { + if cookie, err := r.Cookie(model.SESSION_COOKIE_TOKEN); err == nil { + token = cookie.Value + + if r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML { + return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized) + } + } + } + + // Attempt to parse token out of the query string + if len(token) == 0 { + token = r.URL.Query().Get("access_token") + isTokenFromQueryString = true + } + + if len(token) == 0 { + return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized) + } + + session, err := api.app.GetSession(token) + + if err != nil { + return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized) + } else if !session.IsOAuth && isTokenFromQueryString { + return nil, model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized) + } + + return session, nil +} + +func (api *BuiltInPluginAPI) I18n(id string, r *http.Request) string { + if r != nil { + f, _ := utils.GetTranslationsAndLocale(nil, r) + return f(id) + } + f, _ := utils.GetTranslationsBySystemLocale() + return f(id) +} diff --git a/app/plugin_test.go b/app/plugin_test.go new file mode 100644 index 000000000..a9d872401 --- /dev/null +++ b/app/plugin_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPluginKeyValueStore(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + pluginId := "testpluginid" + + assert.Nil(t, th.App.SetPluginKey(pluginId, "key", []byte("test"))) + ret, err := th.App.GetPluginKey(pluginId, "key") + assert.Nil(t, err) + assert.Equal(t, []byte("test"), ret) + + // Test inserting over existing entries + assert.Nil(t, th.App.SetPluginKey(pluginId, "key", []byte("test2"))) + + // Test getting non-existent key + ret, err = th.App.GetPluginKey(pluginId, "notakey") + assert.Nil(t, err) + assert.Nil(t, ret) + + assert.Nil(t, th.App.DeletePluginKey(pluginId, "stringkey")) + assert.Nil(t, th.App.DeletePluginKey(pluginId, "intkey")) + assert.Nil(t, th.App.DeletePluginKey(pluginId, "postkey")) + assert.Nil(t, th.App.DeletePluginKey(pluginId, "notrealkey")) +} diff --git a/app/plugins.go b/app/plugins.go deleted file mode 100644 index 8f81086df..000000000 --- a/app/plugins.go +++ /dev/null @@ -1,649 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package app - -import ( - "context" - "encoding/json" - "io" - "io/ioutil" - "net/http" - "os" - "path/filepath" - "strings" - - l4g "github.com/alecthomas/log4go" - - "github.com/gorilla/mux" - "github.com/mattermost/mattermost-server/model" - "github.com/mattermost/mattermost-server/utils" - - builtinplugin "github.com/mattermost/mattermost-server/app/plugin" - "github.com/mattermost/mattermost-server/app/plugin/jira" - "github.com/mattermost/mattermost-server/app/plugin/ldapextras" - - "github.com/mattermost/mattermost-server/plugin" - "github.com/mattermost/mattermost-server/plugin/pluginenv" -) - -type PluginAPI struct { - id string - app *App -} - -func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error { - if b, err := json.Marshal(api.app.Config().PluginSettings.Plugins[api.id]); err != nil { - return err - } else { - return json.Unmarshal(b, dest) - } -} - -func (api *PluginAPI) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { - return api.app.CreateTeam(team) -} - -func (api *PluginAPI) DeleteTeam(teamId string) *model.AppError { - return api.app.SoftDeleteTeam(teamId) -} - -func (api *PluginAPI) GetTeam(teamId string) (*model.Team, *model.AppError) { - return api.app.GetTeam(teamId) -} - -func (api *PluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) { - return api.app.GetTeamByName(name) -} - -func (api *PluginAPI) UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { - return api.app.UpdateTeam(team) -} - -func (api *PluginAPI) CreateUser(user *model.User) (*model.User, *model.AppError) { - return api.app.CreateUser(user) -} - -func (api *PluginAPI) DeleteUser(userId string) *model.AppError { - user, err := api.app.GetUser(userId) - if err != nil { - return err - } - _, err = api.app.UpdateActive(user, false) - return err -} - -func (api *PluginAPI) GetUser(userId string) (*model.User, *model.AppError) { - return api.app.GetUser(userId) -} - -func (api *PluginAPI) GetUserByEmail(email string) (*model.User, *model.AppError) { - return api.app.GetUserByEmail(email) -} - -func (api *PluginAPI) GetUserByUsername(name string) (*model.User, *model.AppError) { - return api.app.GetUserByUsername(name) -} - -func (api *PluginAPI) UpdateUser(user *model.User) (*model.User, *model.AppError) { - return api.app.UpdateUser(user, true) -} - -func (api *PluginAPI) CreateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { - return api.app.CreateChannel(channel, false) -} - -func (api *PluginAPI) DeleteChannel(channelId string) *model.AppError { - channel, err := api.app.GetChannel(channelId) - if err != nil { - return err - } - return api.app.DeleteChannel(channel, "") -} - -func (api *PluginAPI) GetChannel(channelId string) (*model.Channel, *model.AppError) { - return api.app.GetChannel(channelId) -} - -func (api *PluginAPI) GetChannelByName(name, teamId string) (*model.Channel, *model.AppError) { - return api.app.GetChannelByName(name, teamId) -} - -func (api *PluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { - return api.app.GetDirectChannel(userId1, userId2) -} - -func (api *PluginAPI) GetGroupChannel(userIds []string) (*model.Channel, *model.AppError) { - return api.app.CreateGroupChannel(userIds, "") -} - -func (api *PluginAPI) UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { - return api.app.UpdateChannel(channel) -} - -func (api *PluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { - return api.app.CreatePostMissingChannel(post, true) -} - -func (api *PluginAPI) DeletePost(postId string) *model.AppError { - _, err := api.app.DeletePost(postId) - return err -} - -func (api *PluginAPI) GetPost(postId string) (*model.Post, *model.AppError) { - return api.app.GetSinglePost(postId) -} - -func (api *PluginAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError) { - return api.app.UpdatePost(post, false) -} - -type BuiltInPluginAPI struct { - id string - router *mux.Router - app *App -} - -func (api *BuiltInPluginAPI) LoadPluginConfiguration(dest interface{}) error { - if b, err := json.Marshal(api.app.Config().PluginSettings.Plugins[api.id]); err != nil { - return err - } else { - return json.Unmarshal(b, dest) - } -} - -func (api *BuiltInPluginAPI) PluginRouter() *mux.Router { - return api.router -} - -func (api *BuiltInPluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) { - return api.app.GetTeamByName(name) -} - -func (api *BuiltInPluginAPI) GetUserByName(name string) (*model.User, *model.AppError) { - return api.app.GetUserByUsername(name) -} - -func (api *BuiltInPluginAPI) GetChannelByName(teamId, name string) (*model.Channel, *model.AppError) { - return api.app.GetChannelByName(name, teamId) -} - -func (api *BuiltInPluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { - return api.app.GetDirectChannel(userId1, userId2) -} - -func (api *BuiltInPluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { - return api.app.CreatePostMissingChannel(post, true) -} - -func (api *BuiltInPluginAPI) GetLdapUserAttributes(userId string, attributes []string) (map[string]string, *model.AppError) { - if api.app.Ldap == nil { - return nil, model.NewAppError("GetLdapUserAttributes", "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - user, err := api.app.GetUser(userId) - if err != nil { - return nil, err - } - - if user.AuthData == nil { - return map[string]string{}, nil - } - - return api.app.Ldap.GetUserAttributes(*user.AuthData, attributes) -} - -func (api *BuiltInPluginAPI) GetSessionFromRequest(r *http.Request) (*model.Session, *model.AppError) { - token := "" - isTokenFromQueryString := false - - // Attempt to parse token out of the header - authHeader := r.Header.Get(model.HEADER_AUTH) - if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == model.HEADER_BEARER { - // Default session token - token = authHeader[7:] - - } else if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == model.HEADER_TOKEN { - // OAuth token - token = authHeader[6:] - } - - // Attempt to parse the token from the cookie - if len(token) == 0 { - if cookie, err := r.Cookie(model.SESSION_COOKIE_TOKEN); err == nil { - token = cookie.Value - - if r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML { - return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized) - } - } - } - - // Attempt to parse token out of the query string - if len(token) == 0 { - token = r.URL.Query().Get("access_token") - isTokenFromQueryString = true - } - - if len(token) == 0 { - return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized) - } - - session, err := api.app.GetSession(token) - - if err != nil { - return nil, model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized) - } else if !session.IsOAuth && isTokenFromQueryString { - return nil, model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized) - } - - return session, nil -} - -func (api *BuiltInPluginAPI) I18n(id string, r *http.Request) string { - if r != nil { - f, _ := utils.GetTranslationsAndLocale(nil, r) - return f(id) - } - f, _ := utils.GetTranslationsBySystemLocale() - return f(id) -} - -func (a *App) initBuiltInPlugins() { - plugins := map[string]builtinplugin.Plugin{ - "jira": &jira.Plugin{}, - "ldapextras": &ldapextras.Plugin{}, - } - for id, p := range plugins { - l4g.Info("Initializing plugin: " + id) - api := &BuiltInPluginAPI{ - id: id, - router: a.Srv.Router.PathPrefix("/plugins/" + id).Subrouter(), - app: a, - } - p.Initialize(api) - } - utils.AddConfigListener(func(before, after *model.Config) { - for _, p := range plugins { - p.OnConfigurationChange() - } - }) - for _, p := range plugins { - p.OnConfigurationChange() - } -} - -// ActivatePlugins will activate any plugins enabled in the config -// and deactivate all other plugins. -func (a *App) ActivatePlugins() { - if a.PluginEnv == nil { - l4g.Error("plugin env not initialized") - return - } - - plugins, err := a.PluginEnv.Plugins() - if err != nil { - l4g.Error("failed to activate plugins: " + err.Error()) - return - } - - for _, plugin := range plugins { - id := plugin.Manifest.Id - - pluginState := &model.PluginState{Enable: false} - if state, ok := a.Config().PluginSettings.PluginStates[id]; ok { - pluginState = state - } - - active := a.PluginEnv.IsPluginActive(id) - - if pluginState.Enable && !active { - if err := a.PluginEnv.ActivatePlugin(id); err != nil { - l4g.Error(err.Error()) - continue - } - - if plugin.Manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_ACTIVATED, "", "", "", nil) - message.Add("manifest", plugin.Manifest.ClientManifest()) - a.Publish(message) - } - - l4g.Info("Activated %v plugin", id) - } else if !pluginState.Enable && active { - if err := a.PluginEnv.DeactivatePlugin(id); err != nil { - l4g.Error(err.Error()) - continue - } - - if plugin.Manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", plugin.Manifest.ClientManifest()) - a.Publish(message) - } - - l4g.Info("Deactivated %v plugin", id) - } - } -} - -// InstallPlugin unpacks and installs a plugin but does not activate it. -func (a *App) InstallPlugin(pluginFile io.Reader) (*model.Manifest, *model.AppError) { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return nil, model.NewAppError("InstallPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - tmpDir, err := ioutil.TempDir("", "plugintmp") - if err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.filesystem.app_error", nil, err.Error(), http.StatusInternalServerError) - } - defer os.RemoveAll(tmpDir) - - if err := utils.ExtractTarGz(pluginFile, tmpDir); err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.extract.app_error", nil, err.Error(), http.StatusBadRequest) - } - - tmpPluginDir := tmpDir - dir, err := ioutil.ReadDir(tmpDir) - if err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.filesystem.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - if len(dir) == 1 && dir[0].IsDir() { - tmpPluginDir = filepath.Join(tmpPluginDir, dir[0].Name()) - } - - manifest, _, err := model.FindManifest(tmpPluginDir) - if err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.manifest.app_error", nil, err.Error(), http.StatusBadRequest) - } - - bundles, err := a.PluginEnv.Plugins() - if err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.install.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - for _, bundle := range bundles { - if bundle.Manifest.Id == manifest.Id { - return nil, model.NewAppError("InstallPlugin", "app.plugin.install_id.app_error", nil, "", http.StatusBadRequest) - } - } - - err = utils.CopyDir(tmpPluginDir, filepath.Join(a.PluginEnv.SearchPath(), manifest.Id)) - if err != nil { - return nil, model.NewAppError("InstallPlugin", "app.plugin.mvdir.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - // Should add manifest validation and error handling here - - return manifest, nil -} - -func (a *App) GetPluginManifests() (*model.PluginsResponse, *model.AppError) { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return nil, model.NewAppError("GetPluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - plugins, err := a.PluginEnv.Plugins() - if err != nil { - return nil, model.NewAppError("GetPluginManifests", "app.plugin.get_plugins.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - resp := &model.PluginsResponse{Active: []*model.Manifest{}, Inactive: []*model.Manifest{}} - for _, plugin := range plugins { - if a.PluginEnv.IsPluginActive(plugin.Manifest.Id) { - resp.Active = append(resp.Active, plugin.Manifest) - } else { - resp.Inactive = append(resp.Inactive, plugin.Manifest) - } - } - - return resp, nil -} - -func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return nil, model.NewAppError("GetActivePluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - plugins := a.PluginEnv.ActivePlugins() - - manifests := make([]*model.Manifest, len(plugins)) - for i, plugin := range plugins { - manifests[i] = plugin.Manifest - } - - return manifests, nil -} - -func (a *App) RemovePlugin(id string) *model.AppError { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - plugins, err := a.PluginEnv.Plugins() - if err != nil { - return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) - } - - var manifest *model.Manifest - for _, p := range plugins { - if p.Manifest.Id == id { - manifest = p.Manifest - break - } - } - - if manifest == nil { - return model.NewAppError("RemovePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) - } - - if a.PluginEnv.IsPluginActive(id) { - err := a.PluginEnv.DeactivatePlugin(id) - if err != nil { - return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) - } - - if manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", manifest.ClientManifest()) - a.Publish(message) - } - } - - err = os.RemoveAll(filepath.Join(a.PluginEnv.SearchPath(), id)) - if err != nil { - return model.NewAppError("RemovePlugin", "app.plugin.remove.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - return nil -} - -// EnablePlugin will set the config for an installed plugin to enabled, triggering activation if inactive. -func (a *App) EnablePlugin(id string) *model.AppError { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - plugins, err := a.PluginEnv.Plugins() - if err != nil { - return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - var manifest *model.Manifest - for _, p := range plugins { - if p.Manifest.Id == id { - manifest = p.Manifest - break - } - } - - if manifest == nil { - return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) - } - - a.UpdateConfig(func(cfg *model.Config) { - cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: true} - }) - - if err := a.SaveConfig(a.Config(), true); err != nil { - return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - return nil -} - -// DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. -func (a *App) DisablePlugin(id string) *model.AppError { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) - } - - plugins, err := a.PluginEnv.Plugins() - if err != nil { - return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - var manifest *model.Manifest - for _, p := range plugins { - if p.Manifest.Id == id { - manifest = p.Manifest - break - } - } - - if manifest == nil { - return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) - } - - a.UpdateConfig(func(cfg *model.Config) { - cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false} - }) - - if err := a.SaveConfig(a.Config(), true); err != nil { - return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - return nil -} - -func (a *App) InitPlugins(pluginPath, webappPath string) { - if !*a.Config().PluginSettings.Enable { - return - } - - if a.PluginEnv != nil { - return - } - - l4g.Info("Starting up plugins") - - err := os.Mkdir(pluginPath, 0744) - if err != nil && !os.IsExist(err) { - l4g.Error("failed to start up plugins: " + err.Error()) - return - } - - err = os.Mkdir(webappPath, 0744) - if err != nil && !os.IsExist(err) { - l4g.Error("failed to start up plugins: " + err.Error()) - return - } - - a.PluginEnv, err = pluginenv.New( - pluginenv.SearchPath(pluginPath), - pluginenv.WebappPath(webappPath), - pluginenv.APIProvider(func(m *model.Manifest) (plugin.API, error) { - return &PluginAPI{ - id: m.Id, - app: a, - }, nil - }), - ) - - if err != nil { - l4g.Error("failed to start up plugins: " + err.Error()) - return - } - - utils.RemoveConfigListener(a.PluginConfigListenerId) - a.PluginConfigListenerId = utils.AddConfigListener(func(prevCfg, cfg *model.Config) { - if a.PluginEnv == nil { - return - } - - if *prevCfg.PluginSettings.Enable && *cfg.PluginSettings.Enable { - a.ActivatePlugins() - } - - for _, err := range a.PluginEnv.Hooks().OnConfigurationChange() { - l4g.Error(err.Error()) - } - }) - - a.ActivatePlugins() -} - -func (a *App) ServePluginRequest(w http.ResponseWriter, r *http.Request) { - if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { - err := model.NewAppError("ServePluginRequest", "app.plugin.disabled.app_error", nil, "Enable plugins to serve plugin requests", http.StatusNotImplemented) - err.Translate(utils.T) - l4g.Error(err.Error()) - w.WriteHeader(err.StatusCode) - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(err.ToJson())) - return - } - - token := "" - - authHeader := r.Header.Get(model.HEADER_AUTH) - if strings.HasPrefix(strings.ToUpper(authHeader), model.HEADER_BEARER+":") { - token = authHeader[len(model.HEADER_BEARER)+1:] - } else if strings.HasPrefix(strings.ToLower(authHeader), model.HEADER_TOKEN+":") { - token = authHeader[len(model.HEADER_TOKEN)+1:] - } else if cookie, _ := r.Cookie(model.SESSION_COOKIE_TOKEN); cookie != nil && (r.Method == "GET" || r.Header.Get(model.HEADER_REQUESTED_WITH) == model.HEADER_REQUESTED_WITH_XML) { - token = cookie.Value - } else { - token = r.URL.Query().Get("access_token") - } - - r.Header.Del("Mattermost-User-Id") - if token != "" { - if session, err := a.GetSession(token); err != nil { - r.Header.Set("Mattermost-User-Id", session.UserId) - } - } - - cookies := r.Cookies() - r.Header.Del("Cookie") - for _, c := range cookies { - if c.Name != model.SESSION_COOKIE_TOKEN { - r.AddCookie(c) - } - } - r.Header.Del(model.HEADER_AUTH) - r.Header.Del("Referer") - - newQuery := r.URL.Query() - newQuery.Del("access_token") - r.URL.RawQuery = newQuery.Encode() - - params := mux.Vars(r) - a.PluginEnv.Hooks().ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "plugin_id", params["plugin_id"]))) -} - -func (a *App) ShutDownPlugins() { - if a.PluginEnv == nil { - return - } - - l4g.Info("Shutting down plugins") - - for _, err := range a.PluginEnv.Shutdown() { - l4g.Error(err.Error()) - } - utils.RemoveConfigListener(a.PluginConfigListenerId) - a.PluginConfigListenerId = "" - a.PluginEnv = nil -} diff --git a/i18n/en.json b/i18n/en.json index c46fd9879..4caa952b3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3554,6 +3554,10 @@ "id": "app.notification.subject.notification.full", "translation": "[{{ .SiteName }}] Notification in {{ .TeamName}} on {{.Month}} {{.Day}}, {{.Year}}" }, + { + "id": "app.plugin.key_value.set.app_error", + "translation": "Unable to set key value. See detailed error for more information." + }, { "id": "app.plugin.activate.app_error", "translation": "Unable to activate extracted plugin. Plugin may already exist and be activated." @@ -4242,6 +4246,14 @@ "id": "mattermost.working_dir", "translation": "Current working directory is %v" }, + { + "id": "model.plugin_key_value.is_valid.plugin_id.app_error", + "translation": "Invalid plugin ID" + }, + { + "id": "model.plugin_key_value.is_valid.key.app_error", + "translation": "Invalid key" + }, { "id": "model.access.is_valid.access_token.app_error", "translation": "Invalid access token" @@ -6434,6 +6446,22 @@ "id": "store.sql_team.update_display_name.app_error", "translation": "We couldn't update the team name" }, + { + "id": "store.sql_plugin_store.delete.app_error", + "translation": "Could not delete plugin key value" + }, + { + "id": "store.sql_plugin_store.get.app_error", + "translation": "Could not get plugin key value" + }, + { + "id": "store.sql_plugin_store.save.app_error", + "translation": "Could not save or update plugin key value" + }, + { + "id": "store.sql_plugin_store.save_unique.app_error", + "translation": "Could not save or update plugin key value due to unique constraint violation" + }, { "id": "store.sql_user.analytics_get_inactive_users_count.app_error", "translation": "We could not count the inactive users" diff --git a/model/plugin_key_value.go b/model/plugin_key_value.go new file mode 100644 index 000000000..b25b4c170 --- /dev/null +++ b/model/plugin_key_value.go @@ -0,0 +1,26 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "net/http" +) + +type PluginKeyValue struct { + PluginId string `json:"plugin_id"` + Key string `json:"key" db:"PKey"` + Value []byte `json:"value" db:"PValue"` +} + +func (kv *PluginKeyValue) IsValid() *AppError { + if len(kv.PluginId) == 0 { + return NewAppError("PluginKeyValue.IsValid", "model.plugin_key_value.is_valid.plugin_id.app_error", nil, "key="+kv.Key, http.StatusBadRequest) + } + + if len(kv.Key) == 0 { + return NewAppError("PluginKeyValue.IsValid", "model.plugin_key_value.is_valid.key.app_error", nil, "key="+kv.Key, http.StatusBadRequest) + } + + return nil +} diff --git a/model/plugin_key_value_test.go b/model/plugin_key_value_test.go new file mode 100644 index 000000000..82dd7d561 --- /dev/null +++ b/model/plugin_key_value_test.go @@ -0,0 +1,22 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPluginKeyIsValid(t *testing.T) { + kv := PluginKeyValue{PluginId: "someid", Key: "somekey", Value: []byte("somevalue")} + assert.Nil(t, kv.IsValid()) + + kv.PluginId = "" + assert.NotNil(t, kv.IsValid()) + + kv.PluginId = "someid" + kv.Key = "" + assert.NotNil(t, kv.IsValid()) +} diff --git a/plugin/api.go b/plugin/api.go index 8d27bc794..4bcfd112b 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -79,6 +79,20 @@ type API interface { // GetPost gets a post. GetPost(postId string) (*model.Post, *model.AppError) - // Update post updates a post. + // UpdatePost updates a post. UpdatePost(post *model.Post) (*model.Post, *model.AppError) + + // KeyValueStore returns an object for accessing the persistent key value storage. + KeyValueStore() KeyValueStore +} + +type KeyValueStore interface { + // Set will store a key-value pair, unique per plugin. + Set(key string, value []byte) *model.AppError + + // Get will retrieve a value based on the key. Returns nil for non-existent keys. + Get(key string) ([]byte, *model.AppError) + + // Delete will remove a key-value pair. Returns nil for non-existent keys. + Delete(key string) *model.AppError } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index c0e77648b..37052b4cf 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -12,9 +12,15 @@ import ( type API struct { mock.Mock + Store *KeyValueStore +} + +type KeyValueStore struct { + mock.Mock } var _ plugin.API = (*API)(nil) +var _ plugin.KeyValueStore = (*KeyValueStore)(nil) func (m *API) LoadPluginConfiguration(dest interface{}) error { return m.Called(dest).Error(0) @@ -235,3 +241,35 @@ func (m *API) UpdatePost(post *model.Post) (*model.Post, *model.AppError) { err, _ := ret.Get(1).(*model.AppError) return postOut, err } + +func (m *API) KeyValueStore() plugin.KeyValueStore { + return m.Store +} + +func (m *KeyValueStore) Set(key string, value []byte) *model.AppError { + ret := m.Called(key, value) + if f, ok := ret.Get(0).(func(string, []byte) *model.AppError); ok { + return f(key, value) + } + err, _ := ret.Get(0).(*model.AppError) + return err +} + +func (m *KeyValueStore) Get(key string) ([]byte, *model.AppError) { + ret := m.Called(key) + if f, ok := ret.Get(0).(func(string) ([]byte, *model.AppError)); ok { + return f(key) + } + psv, _ := ret.Get(0).([]byte) + err, _ := ret.Get(1).(*model.AppError) + return psv, err +} + +func (m *KeyValueStore) Delete(key string) *model.AppError { + ret := m.Called(key) + if f, ok := ret.Get(0).(func(string) *model.AppError); ok { + return f(key) + } + err, _ := ret.Get(0).(*model.AppError) + return err +} diff --git a/plugin/rpcplugin/api.go b/plugin/rpcplugin/api.go index 98333b1d9..f2068e815 100644 --- a/plugin/rpcplugin/api.go +++ b/plugin/rpcplugin/api.go @@ -259,6 +259,41 @@ func (api *LocalAPI) UpdatePost(args *model.Post, reply *APIPostReply) error { return nil } +type APIKeyValueStoreReply struct { + Value []byte + Error *model.AppError +} + +type APIKeyValueStoreSetArgs struct { + Key string + Value []byte +} + +func (api *LocalAPI) KeyValueStoreSet(args *APIKeyValueStoreSetArgs, reply *APIErrorReply) error { + err := api.api.KeyValueStore().Set(args.Key, args.Value) + *reply = APIErrorReply{ + Error: err, + } + return nil +} + +func (api *LocalAPI) KeyValueStoreGet(args string, reply *APIKeyValueStoreReply) error { + v, err := api.api.KeyValueStore().Get(args) + *reply = APIKeyValueStoreReply{ + Value: v, + Error: err, + } + return nil +} + +func (api *LocalAPI) KeyValueStoreDelete(args string, reply *APIErrorReply) error { + err := api.api.KeyValueStore().Delete(args) + *reply = APIErrorReply{ + Error: err, + } + return nil +} + func ServeAPI(api plugin.API, conn io.ReadWriteCloser, muxer *Muxer) { server := rpc.NewServer() server.Register(&LocalAPI{ @@ -269,11 +304,17 @@ func ServeAPI(api plugin.API, conn io.ReadWriteCloser, muxer *Muxer) { } type RemoteAPI struct { - client *rpc.Client - muxer *Muxer + client *rpc.Client + muxer *Muxer + keyValueStore *RemoteKeyValueStore +} + +type RemoteKeyValueStore struct { + api *RemoteAPI } var _ plugin.API = (*RemoteAPI)(nil) +var _ plugin.KeyValueStore = (*RemoteKeyValueStore)(nil) func (api *RemoteAPI) LoadPluginConfiguration(dest interface{}) error { var config []byte @@ -467,13 +508,47 @@ func (api *RemoteAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError return reply.Post, reply.Error } +func (api *RemoteAPI) KeyValueStore() plugin.KeyValueStore { + return api.keyValueStore +} + +func (s *RemoteKeyValueStore) Set(key string, value []byte) *model.AppError { + var reply APIErrorReply + if err := s.api.client.Call("LocalAPI.KeyValueStoreSet", &APIKeyValueStoreSetArgs{Key: key, Value: value}, &reply); err != nil { + return model.NewAppError("RemoteAPI.KeyValueStoreSet", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Error +} + +func (s *RemoteKeyValueStore) Get(key string) ([]byte, *model.AppError) { + var reply APIKeyValueStoreReply + if err := s.api.client.Call("LocalAPI.KeyValueStoreGet", key, &reply); err != nil { + return nil, model.NewAppError("RemoteAPI.KeyValueStoreGet", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Value, reply.Error +} + +func (s *RemoteKeyValueStore) Delete(key string) *model.AppError { + var reply APIErrorReply + if err := s.api.client.Call("LocalAPI.KeyValueStoreDelete", key, &reply); err != nil { + return model.NewAppError("RemoteAPI.KeyValueStoreDelete", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Error +} + func (h *RemoteAPI) Close() error { return h.client.Close() } func ConnectAPI(conn io.ReadWriteCloser, muxer *Muxer) *RemoteAPI { - return &RemoteAPI{ - client: rpc.NewClient(conn), - muxer: muxer, + remoteKeyValueStore := &RemoteKeyValueStore{} + remoteApi := &RemoteAPI{ + client: rpc.NewClient(conn), + muxer: muxer, + keyValueStore: remoteKeyValueStore, } + + remoteKeyValueStore.api = remoteApi + + return remoteApi } diff --git a/plugin/rpcplugin/api_test.go b/plugin/rpcplugin/api_test.go index 080f2825f..0c7321162 100644 --- a/plugin/rpcplugin/api_test.go +++ b/plugin/rpcplugin/api_test.go @@ -34,7 +34,8 @@ func testAPIRPC(api plugin.API, f func(plugin.API)) { } func TestAPI(t *testing.T) { - var api plugintest.API + keyValueStore := &plugintest.KeyValueStore{} + api := plugintest.API{Store: keyValueStore} defer api.AssertExpectations(t) type Config struct { @@ -199,5 +200,20 @@ func TestAPI(t *testing.T) { post, err = remote.UpdatePost(testPost) assert.Equal(t, testPost, post) assert.Nil(t, err) + + api.KeyValueStore().(*plugintest.KeyValueStore).On("Set", "thekey", []byte("thevalue")).Return(nil).Once() + err = remote.KeyValueStore().Set("thekey", []byte("thevalue")) + assert.Nil(t, err) + + api.KeyValueStore().(*plugintest.KeyValueStore).On("Get", "thekey").Return(func(key string) ([]byte, *model.AppError) { + return []byte("thevalue"), nil + }).Once() + ret, err := remote.KeyValueStore().Get("thekey") + assert.Nil(t, err) + assert.Equal(t, []byte("thevalue"), ret) + + api.KeyValueStore().(*plugintest.KeyValueStore).On("Delete", "thekey").Return(nil).Once() + err = remote.KeyValueStore().Delete("thekey") + assert.Nil(t, err) }) } diff --git a/store/layered_store.go b/store/layered_store.go index 7e6a06086..ecf02864c 100644 --- a/store/layered_store.go +++ b/store/layered_store.go @@ -153,6 +153,10 @@ func (s *LayeredStore) UserAccessToken() UserAccessTokenStore { return s.DatabaseLayer.UserAccessToken() } +func (s *LayeredStore) Plugin() PluginStore { + return s.DatabaseLayer.Plugin() +} + func (s *LayeredStore) MarkSystemRanUnitTests() { s.DatabaseLayer.MarkSystemRanUnitTests() } diff --git a/store/sqlstore/plugin_store.go b/store/sqlstore/plugin_store.go new file mode 100644 index 000000000..c7c075d8a --- /dev/null +++ b/store/sqlstore/plugin_store.go @@ -0,0 +1,92 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package sqlstore + +import ( + "database/sql" + "fmt" + "net/http" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/store" +) + +type SqlPluginStore struct { + SqlStore +} + +func NewSqlPluginStore(sqlStore SqlStore) store.PluginStore { + s := &SqlPluginStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.PluginKeyValue{}, "PluginKeyValueStore").SetKeys(false, "PluginId", "Key") + table.ColMap("Value").SetMaxSize(8192) + } + + return s +} + +func (ps SqlPluginStore) CreateIndexesIfNotExists() { +} + +func (ps SqlPluginStore) SaveOrUpdate(kv *model.PluginKeyValue) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + if result.Err = kv.IsValid(); result.Err != nil { + return + } + + if ps.DriverName() == model.DATABASE_DRIVER_POSTGRES { + // Unfortunately PostgreSQL pre-9.5 does not have an atomic upsert, so we use + // separate update and insert queries to accomplish our upsert + if rowsAffected, err := ps.GetMaster().Update(kv); err != nil { + result.Err = model.NewAppError("SqlPluginStore.SaveOrUpdate", "store.sql_plugin_store.save.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } else if rowsAffected == 0 { + // No rows were affected by the update, so let's try an insert + if err := ps.GetMaster().Insert(kv); err != nil { + // If the error is from unique constraints violation, it's the result of a + // valid race and we can report success. Otherwise we have a real error and + // need to return it + if !IsUniqueConstraintError(err, []string{"PRIMARY", "PluginId", "Key", "PKey"}) { + result.Err = model.NewAppError("SqlPluginStore.SaveOrUpdate", "store.sql_plugin_store.save.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + } + } + } else if ps.DriverName() == model.DATABASE_DRIVER_MYSQL { + if _, err := ps.GetMaster().Exec("INSERT INTO PluginKeyValueStore (PluginId, PKey, PValue) VALUES(:PluginId, :Key, :Value) ON DUPLICATE KEY UPDATE PValue = :Value", map[string]interface{}{"PluginId": kv.PluginId, "Key": kv.Key, "Value": kv.Value}); err != nil { + result.Err = model.NewAppError("SqlPluginStore.SaveOrUpdate", "store.sql_plugin_store.save.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + } + + result.Data = kv + }) +} + +func (ps SqlPluginStore) Get(pluginId, key string) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + var kv *model.PluginKeyValue + + if err := ps.GetReplica().SelectOne(&kv, "SELECT * FROM PluginKeyValueStore WHERE PluginId = :PluginId AND PKey = :Key", map[string]interface{}{"PluginId": pluginId, "Key": key}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlPluginStore.Get", "store.sql_plugin_store.get.app_error", nil, fmt.Sprintf("plugin_id=%v, key=%v, err=%v", pluginId, key, err.Error()), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlPluginStore.Get", "store.sql_plugin_store.get.app_error", nil, fmt.Sprintf("plugin_id=%v, key=%v, err=%v", pluginId, key, err.Error()), http.StatusInternalServerError) + } + } else { + result.Data = kv + } + }) +} + +func (ps SqlPluginStore) Delete(pluginId, key string) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + if _, err := ps.GetMaster().Exec("DELETE FROM PluginKeyValueStore WHERE PluginId = :PluginId AND PKey = :Key", map[string]interface{}{"PluginId": pluginId, "Key": key}); err != nil { + result.Err = model.NewAppError("SqlPluginStore.Delete", "store.sql_plugin_store.delete.app_error", nil, fmt.Sprintf("plugin_id=%v, key=%v, err=%v", pluginId, key, err.Error()), http.StatusInternalServerError) + } else { + result.Data = true + } + }) +} diff --git a/store/sqlstore/plugin_store_test.go b/store/sqlstore/plugin_store_test.go new file mode 100644 index 000000000..d2e883235 --- /dev/null +++ b/store/sqlstore/plugin_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/store/storetest" +) + +func TestPluginStore(t *testing.T) { + StoreTest(t, storetest.TestPluginStore) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index cc43778f5..cfdd7a552 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -85,5 +85,6 @@ type SqlStore interface { FileInfo() store.FileInfoStore Reaction() store.ReactionStore Job() store.JobStore + Plugin() store.PluginStore UserAccessToken() store.UserAccessTokenStore } diff --git a/store/sqlstore/supplier.go b/store/sqlstore/supplier.go index a90ea6388..dbe4aa92c 100644 --- a/store/sqlstore/supplier.go +++ b/store/sqlstore/supplier.go @@ -84,6 +84,7 @@ type SqlSupplierOldStores struct { reaction store.ReactionStore job store.JobStore userAccessToken store.UserAccessTokenStore + plugin store.PluginStore } type SqlSupplier struct { @@ -129,6 +130,7 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter supplier.oldStores.fileInfo = NewSqlFileInfoStore(supplier, metrics) supplier.oldStores.job = NewSqlJobStore(supplier) supplier.oldStores.userAccessToken = NewSqlUserAccessTokenStore(supplier) + supplier.oldStores.plugin = NewSqlPluginStore(supplier) initSqlSupplierReactions(supplier) @@ -161,6 +163,7 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter supplier.oldStores.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists() supplier.oldStores.job.(*SqlJobStore).CreateIndexesIfNotExists() supplier.oldStores.userAccessToken.(*SqlUserAccessTokenStore).CreateIndexesIfNotExists() + supplier.oldStores.plugin.(*SqlPluginStore).CreateIndexesIfNotExists() supplier.oldStores.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() @@ -798,6 +801,10 @@ func (ss *SqlSupplier) UserAccessToken() store.UserAccessTokenStore { return ss.oldStores.userAccessToken } +func (ss *SqlSupplier) Plugin() store.PluginStore { + return ss.oldStores.plugin +} + func (ss *SqlSupplier) DropAllTables() { ss.master.TruncateTables() } diff --git a/store/store.go b/store/store.go index eada8f395..7997000ec 100644 --- a/store/store.go +++ b/store/store.go @@ -63,6 +63,7 @@ type Store interface { Reaction() ReactionStore Job() JobStore UserAccessToken() UserAccessTokenStore + Plugin() PluginStore MarkSystemRanUnitTests() Close() DropAllTables() @@ -440,3 +441,9 @@ type UserAccessTokenStore interface { UpdateTokenEnable(tokenId string) StoreChannel UpdateTokenDisable(tokenId string) StoreChannel } + +type PluginStore interface { + SaveOrUpdate(keyVal *model.PluginKeyValue) StoreChannel + Get(pluginId, key string) StoreChannel + Delete(pluginId, key string) StoreChannel +} diff --git a/store/storetest/mocks/LayeredStoreDatabaseLayer.go b/store/storetest/mocks/LayeredStoreDatabaseLayer.go index 1eb09c343..c3b8bbb60 100644 --- a/store/storetest/mocks/LayeredStoreDatabaseLayer.go +++ b/store/storetest/mocks/LayeredStoreDatabaseLayer.go @@ -221,6 +221,22 @@ func (_m *LayeredStoreDatabaseLayer) OAuth() store.OAuthStore { return r0 } +// Plugin provides a mock function with given fields: +func (_m *LayeredStoreDatabaseLayer) Plugin() store.PluginStore { + ret := _m.Called() + + var r0 store.PluginStore + if rf, ok := ret.Get(0).(func() store.PluginStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PluginStore) + } + } + + return r0 +} + // Post provides a mock function with given fields: func (_m *LayeredStoreDatabaseLayer) Post() store.PostStore { ret := _m.Called() diff --git a/store/storetest/mocks/PluginStore.go b/store/storetest/mocks/PluginStore.go new file mode 100644 index 000000000..920b0f63c --- /dev/null +++ b/store/storetest/mocks/PluginStore.go @@ -0,0 +1,62 @@ +// Code generated by mockery v1.0.0 + +// Regenerate this file using `make store-mocks`. + +package mocks + +import mock "github.com/stretchr/testify/mock" +import model "github.com/mattermost/mattermost-server/model" +import store "github.com/mattermost/mattermost-server/store" + +// PluginStore is an autogenerated mock type for the PluginStore type +type PluginStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: pluginId, key +func (_m *PluginStore) Delete(pluginId string, key string) store.StoreChannel { + ret := _m.Called(pluginId, key) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string) store.StoreChannel); ok { + r0 = rf(pluginId, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// Get provides a mock function with given fields: pluginId, key +func (_m *PluginStore) Get(pluginId string, key string) store.StoreChannel { + ret := _m.Called(pluginId, key) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string) store.StoreChannel); ok { + r0 = rf(pluginId, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// SaveOrUpdate provides a mock function with given fields: keyVal +func (_m *PluginStore) SaveOrUpdate(keyVal *model.PluginKeyValue) store.StoreChannel { + ret := _m.Called(keyVal) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(*model.PluginKeyValue) store.StoreChannel); ok { + r0 = rf(keyVal) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} diff --git a/store/storetest/mocks/SqlStore.go b/store/storetest/mocks/SqlStore.go index eda8cb39e..b9b962101 100644 --- a/store/storetest/mocks/SqlStore.go +++ b/store/storetest/mocks/SqlStore.go @@ -143,6 +143,20 @@ func (_m *SqlStore) CreateColumnIfNotExists(tableName string, columnName string, return r0 } +// CreateCompositeIndexIfNotExists provides a mock function with given fields: indexName, tableName, columnNames +func (_m *SqlStore) CreateCompositeIndexIfNotExists(indexName string, tableName string, columnNames []string) bool { + ret := _m.Called(indexName, tableName, columnNames) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string, []string) bool); ok { + r0 = rf(indexName, tableName, columnNames) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // CreateFullTextIndexIfNotExists provides a mock function with given fields: indexName, tableName, columnName func (_m *SqlStore) CreateFullTextIndexIfNotExists(indexName string, tableName string, columnName string) bool { ret := _m.Called(indexName, tableName, columnName) @@ -404,6 +418,22 @@ func (_m *SqlStore) OAuth() store.OAuthStore { return r0 } +// Plugin provides a mock function with given fields: +func (_m *SqlStore) Plugin() store.PluginStore { + ret := _m.Called() + + var r0 store.PluginStore + if rf, ok := ret.Get(0).(func() store.PluginStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PluginStore) + } + } + + return r0 +} + // Post provides a mock function with given fields: func (_m *SqlStore) Post() store.PostStore { ret := _m.Called() diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 166b5b98a..85ed10d35 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -203,6 +203,22 @@ func (_m *Store) OAuth() store.OAuthStore { return r0 } +// Plugin provides a mock function with given fields: +func (_m *Store) Plugin() store.PluginStore { + ret := _m.Called() + + var r0 store.PluginStore + if rf, ok := ret.Get(0).(func() store.PluginStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PluginStore) + } + } + + return r0 +} + // Post provides a mock function with given fields: func (_m *Store) Post() store.PostStore { ret := _m.Called() diff --git a/store/storetest/plugin_store.go b/store/storetest/plugin_store.go new file mode 100644 index 000000000..3d7d0ec05 --- /dev/null +++ b/store/storetest/plugin_store.go @@ -0,0 +1,69 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/store" + "github.com/stretchr/testify/assert" +) + +func TestPluginStore(t *testing.T, ss store.Store) { + t.Run("PluginSaveGet", func(t *testing.T) { testPluginSaveGet(t, ss) }) + t.Run("PluginDelete", func(t *testing.T) { testPluginDelete(t, ss) }) +} + +func testPluginSaveGet(t *testing.T, ss store.Store) { + kv := &model.PluginKeyValue{ + PluginId: model.NewId(), + Key: model.NewId(), + Value: []byte(model.NewId()), + } + + if result := <-ss.Plugin().SaveOrUpdate(kv); result.Err != nil { + t.Fatal(result.Err) + } + + defer func() { + <-ss.Plugin().Delete(kv.PluginId, kv.Key) + }() + + if result := <-ss.Plugin().Get(kv.PluginId, kv.Key); result.Err != nil { + t.Fatal(result.Err) + } else { + received := result.Data.(*model.PluginKeyValue) + assert.Equal(t, kv.PluginId, received.PluginId) + assert.Equal(t, kv.Key, received.Key) + assert.Equal(t, kv.Value, received.Value) + } + + // Try inserting when already exists + kv.Value = []byte(model.NewId()) + if result := <-ss.Plugin().SaveOrUpdate(kv); result.Err != nil { + t.Fatal(result.Err) + } + + if result := <-ss.Plugin().Get(kv.PluginId, kv.Key); result.Err != nil { + t.Fatal(result.Err) + } else { + received := result.Data.(*model.PluginKeyValue) + assert.Equal(t, kv.PluginId, received.PluginId) + assert.Equal(t, kv.Key, received.Key) + assert.Equal(t, kv.Value, received.Value) + } +} + +func testPluginDelete(t *testing.T, ss store.Store) { + kv := store.Must(ss.Plugin().SaveOrUpdate(&model.PluginKeyValue{ + PluginId: model.NewId(), + Key: model.NewId(), + Value: []byte(model.NewId()), + })).(*model.PluginKeyValue) + + if result := <-ss.Plugin().Delete(kv.PluginId, kv.Key); result.Err != nil { + t.Fatal(result.Err) + } +} diff --git a/store/storetest/store.go b/store/storetest/store.go index 7201df6ec..55545decb 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -41,6 +41,7 @@ type Store struct { ReactionStore mocks.ReactionStore JobStore mocks.JobStore UserAccessTokenStore mocks.UserAccessTokenStore + PluginStore mocks.PluginStore } func (s *Store) Team() store.TeamStore { return &s.TeamStore } @@ -65,6 +66,7 @@ func (s *Store) FileInfo() store.FileInfoStore { return &s.FileI func (s *Store) Reaction() store.ReactionStore { return &s.ReactionStore } func (s *Store) Job() store.JobStore { return &s.JobStore } func (s *Store) UserAccessToken() store.UserAccessTokenStore { return &s.UserAccessTokenStore } +func (s *Store) Plugin() store.PluginStore { return &s.PluginStore } func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } func (s *Store) Close() { /* do nothing */ } func (s *Store) DropAllTables() { /* do nothing */ } @@ -96,5 +98,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ReactionStore, &s.JobStore, &s.UserAccessTokenStore, + &s.PluginStore, ) } -- cgit v1.2.3-1-g7c22