From 899ab31fff9b34bc125faf75b79a89e390deb2cf Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Fri, 1 Sep 2017 09:00:27 -0400 Subject: Implement experimental REST API endpoints for plugins (#7279) * Implement experimental REST API endpoints for plugins * Updates per feedback and rebase * Update tests * Further updates * Update extraction of plugins * Use OS temp dir for plugins instead of search path * Fail extraction on paths that attempt to traverse upward * Update pluginenv ActivePlugins() --- api4/api.go | 7 +++ api4/context.go | 12 ++++++ api4/params.go | 5 +++ api4/plugin.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++ api4/plugin_test.go | 115 +++++++++++++++++++++++++++++++++++++++++++++++++ api4/system.go | 1 + 6 files changed, 260 insertions(+) create mode 100644 api4/plugin.go create mode 100644 api4/plugin_test.go (limited to 'api4') diff --git a/api4/api.go b/api4/api.go index 8ed94c193..3a4f2c412 100644 --- a/api4/api.go +++ b/api4/api.go @@ -53,6 +53,9 @@ type Routes struct { Files *mux.Router // 'api/v4/files' File *mux.Router // 'api/v4/files/{file_id:[A-Za-z0-9]+}' + Plugins *mux.Router // 'api/v4/plugins' + Plugin *mux.Router // 'api/v4/plugins/{plugin_id:[A-Za-z0-9_-]+}' + PublicFile *mux.Router // 'files/{file_id:[A-Za-z0-9]+}/public' Commands *mux.Router // 'api/v4/commands' @@ -146,6 +149,9 @@ func InitApi(full bool) { BaseRoutes.File = BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter() BaseRoutes.PublicFile = BaseRoutes.Root.PathPrefix("/files/{file_id:[A-Za-z0-9]+}/public").Subrouter() + BaseRoutes.Plugins = BaseRoutes.ApiRoot.PathPrefix("/plugins").Subrouter() + BaseRoutes.Plugin = BaseRoutes.Plugins.PathPrefix("/{plugin_id:[A-Za-z0-9\\_\\-]+}").Subrouter() + BaseRoutes.Commands = BaseRoutes.ApiRoot.PathPrefix("/commands").Subrouter() BaseRoutes.Command = BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter() @@ -205,6 +211,7 @@ func InitApi(full bool) { InitReaction() InitWebrtc() InitOpenGraph() + InitPlugin() app.Srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(Handle404)) diff --git a/api4/context.go b/api4/context.go index 3ea67b30c..2c0e54ea0 100644 --- a/api4/context.go +++ b/api4/context.go @@ -428,6 +428,18 @@ func (c *Context) RequireFileId() *Context { return c } +func (c *Context) RequirePluginId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.PluginId) == 0 { + c.SetInvalidUrlParam("plugin_id") + } + + return c +} + func (c *Context) RequireReportId() *Context { if c.Err != nil { return c diff --git a/api4/params.go b/api4/params.go index 8b1d0febe..1f0fe8e63 100644 --- a/api4/params.go +++ b/api4/params.go @@ -24,6 +24,7 @@ type ApiParams struct { ChannelId string PostId string FileId string + PluginId string CommandId string HookId string ReportId string @@ -78,6 +79,10 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.FileId = val } + if val, ok := props["plugin_id"]; ok { + params.PluginId = val + } + if val, ok := props["command_id"]; ok { params.CommandId = val } diff --git a/api4/plugin.go b/api4/plugin.go new file mode 100644 index 000000000..109695174 --- /dev/null +++ b/api4/plugin.go @@ -0,0 +1,120 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +// EXPERIMENTAL - SUBJECT TO CHANGE + +package api4 + +import ( + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +const ( + MAXIMUM_PLUGIN_FILE_SIZE = 50 * 1024 * 1024 +) + +func InitPlugin() { + l4g.Debug("EXPERIMENTAL: Initializing plugin api") + + BaseRoutes.Plugins.Handle("", ApiSessionRequired(uploadPlugin)).Methods("POST") + BaseRoutes.Plugins.Handle("", ApiSessionRequired(getPlugins)).Methods("GET") + BaseRoutes.Plugin.Handle("", ApiSessionRequired(removePlugin)).Methods("DELETE") + +} + +func uploadPlugin(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.PluginSettings.Enable { + c.Err = model.NewAppError("uploadPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if err := r.ParseMultipartForm(MAXIMUM_PLUGIN_FILE_SIZE); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + m := r.MultipartForm + + pluginArray, ok := m.File["plugin"] + if !ok { + c.Err = model.NewAppError("uploadPlugin", "api.plugin.upload.no_file.app_error", nil, "", http.StatusBadRequest) + return + } + + if len(pluginArray) <= 0 { + c.Err = model.NewAppError("uploadPlugin", "api.plugin.upload.array.app_error", nil, "", http.StatusBadRequest) + return + } + + file, err := pluginArray[0].Open() + if err != nil { + c.Err = model.NewAppError("uploadPlugin", "api.plugin.upload.file.app_error", nil, "", http.StatusBadRequest) + return + } + defer file.Close() + + manifest, unpackErr := app.UnpackAndActivatePlugin(file) + + if unpackErr != nil { + c.Err = unpackErr + return + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte(manifest.ToJson())) +} + +func getPlugins(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.PluginSettings.Enable { + c.Err = model.NewAppError("getPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + manifests, err := app.GetActivePluginManifests() + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.ManifestListToJson(manifests))) +} + +func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePluginId() + if c.Err != nil { + return + } + + if !*utils.Cfg.PluginSettings.Enable { + c.Err = model.NewAppError("getPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + err := app.RemovePlugin(c.Params.PluginId) + if err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/plugin_test.go b/api4/plugin_test.go new file mode 100644 index 000000000..c1d6c987c --- /dev/null +++ b/api4/plugin_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlugin(t *testing.T) { + pluginDir, err := ioutil.TempDir("", "mm-plugin-test") + require.NoError(t, err) + defer func() { + os.RemoveAll(pluginDir) + }() + webappDir, err := ioutil.TempDir("", "mm-webapp-test") + require.NoError(t, err) + defer func() { + os.RemoveAll(webappDir) + }() + + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + + app.StartupPlugins(pluginDir, webappDir) + + enablePlugins := *utils.Cfg.PluginSettings.Enable + defer func() { + *utils.Cfg.PluginSettings.Enable = enablePlugins + }() + *utils.Cfg.PluginSettings.Enable = true + + path, _ := utils.FindDir("tests") + file, err := os.Open(path + "/testplugin.tar.gz") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + // Successful upload + manifest, resp := th.SystemAdminClient.UploadPlugin(file) + defer func() { + os.RemoveAll("plugins/testplugin") + }() + CheckNoError(t, resp) + + assert.Equal(t, "testplugin", manifest.Id) + + // Upload error cases + _, resp = th.SystemAdminClient.UploadPlugin(bytes.NewReader([]byte("badfile"))) + CheckBadRequestStatus(t, resp) + + *utils.Cfg.PluginSettings.Enable = false + _, resp = th.SystemAdminClient.UploadPlugin(file) + CheckNotImplementedStatus(t, resp) + + *utils.Cfg.PluginSettings.Enable = true + _, resp = th.Client.UploadPlugin(file) + CheckForbiddenStatus(t, resp) + + // Successful get + manifests, resp := th.SystemAdminClient.GetPlugins() + CheckNoError(t, resp) + + found := false + for _, m := range manifests { + if m.Id == manifest.Id { + found = true + } + } + + assert.True(t, found) + + // Get error cases + *utils.Cfg.PluginSettings.Enable = false + _, resp = th.SystemAdminClient.GetPlugins() + CheckNotImplementedStatus(t, resp) + + *utils.Cfg.PluginSettings.Enable = true + _, resp = th.Client.GetPlugins() + CheckForbiddenStatus(t, resp) + + // Successful remove + ok, resp := th.SystemAdminClient.RemovePlugin(manifest.Id) + CheckNoError(t, resp) + + assert.True(t, ok) + + // Remove error cases + ok, resp = th.SystemAdminClient.RemovePlugin(manifest.Id) + CheckBadRequestStatus(t, resp) + + assert.False(t, ok) + + *utils.Cfg.PluginSettings.Enable = false + _, resp = th.SystemAdminClient.RemovePlugin(manifest.Id) + CheckNotImplementedStatus(t, resp) + + *utils.Cfg.PluginSettings.Enable = true + _, resp = th.Client.RemovePlugin(manifest.Id) + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.RemovePlugin("bad.id") + CheckNotFoundStatus(t, resp) + + app.Srv.PluginEnv = nil +} diff --git a/api4/system.go b/api4/system.go index 2ad408e13..8f98afedb 100644 --- a/api4/system.go +++ b/api4/system.go @@ -244,6 +244,7 @@ func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { } respCfg["NoAccounts"] = strconv.FormatBool(app.IsFirstUserAccount()) + respCfg["Plugins"] = app.GetPluginsForClientConfig() w.Write([]byte(model.MapToJson(respCfg))) } -- cgit v1.2.3-1-g7c22