diff --git a/api4/helpers.go b/api4/helpers.go new file mode 100644 index 0000000000..6b07a279ce --- /dev/null +++ b/api4/helpers.go @@ -0,0 +1,25 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/url" + "strconv" + + "github.com/pkg/errors" +) + +func parseInt(u *url.URL, name string, defaultValue int) (int, error) { + valueStr := u.Query().Get(name) + if valueStr == "" { + return defaultValue, nil + } + + value, err := strconv.Atoi(valueStr) + if err != nil { + return 0, errors.Wrapf(err, "failed to parse %s as integer", name) + } + + return value, nil +} diff --git a/api4/plugin.go b/api4/plugin.go index 1f045afc71..18ce219975 100644 --- a/api4/plugin.go +++ b/api4/plugin.go @@ -7,6 +7,7 @@ package api4 import ( "bytes" + "encoding/json" "io/ioutil" "net/http" "net/url" @@ -36,6 +37,8 @@ func (api *API) InitPlugin() { api.BaseRoutes.Plugin.Handle("/disable", api.ApiSessionRequired(disablePlugin)).Methods("POST") api.BaseRoutes.Plugins.Handle("/webapp", api.ApiHandler(getWebappPlugins)).Methods("GET") + + api.BaseRoutes.Plugins.Handle("/marketplace", api.ApiSessionRequired(getMarketplacePlugins)).Methods("GET") } func uploadPlugin(c *Context, w http.ResponseWriter, r *http.Request) { @@ -239,6 +242,43 @@ func getWebappPlugins(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.ManifestListToJson(clientManifests))) } +func getMarketplacePlugins(c *Context, w http.ResponseWriter, r *http.Request) { + if !*c.App.Config().PluginSettings.Enable { + c.Err = model.NewAppError("getMarketplacePlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !*c.App.Config().PluginSettings.EnableMarketplace { + c.Err = model.NewAppError("getMarketplacePlugins", "app.plugin.marketplace_disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + filter, err := parseMarketplacePluginFilter(r.URL) + if err != nil { + c.Err = model.NewAppError("getMarketplacePlugins", "app.plugin.marshal.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + plugins, appErr := c.App.GetMarketplacePlugins(filter) + if appErr != nil { + c.Err = appErr + return + } + + json, err := json.Marshal(plugins) + if err != nil { + c.Err = model.NewAppError("getMarketplacePlugins", "app.plugin.marshal.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(json) +} + func enablePlugin(c *Context, w http.ResponseWriter, r *http.Request) { c.RequirePluginId() if c.Err != nil { @@ -286,3 +326,25 @@ func disablePlugin(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } + +func parseMarketplacePluginFilter(u *url.URL) (*model.MarketplacePluginFilter, error) { + page, err := parseInt(u, "page", 0) + if err != nil { + return nil, err + } + + perPage, err := parseInt(u, "per_page", 100) + if err != nil { + return nil, err + } + + filter := u.Query().Get("filter") + serverVersion := u.Query().Get("server_version") + + return &model.MarketplacePluginFilter{ + Page: page, + PerPage: perPage, + Filter: filter, + ServerVersion: serverVersion, + }, nil +} diff --git a/api4/plugin_test.go b/api4/plugin_test.go index fef1ac3918..4dbed6ba4a 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -11,6 +11,8 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" + "strings" "testing" "time" @@ -36,9 +38,7 @@ func TestPlugin(t *testing.T) { path, _ := fileutils.FindDir("tests") tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin.tar.gz")) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) // Install from URL testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { @@ -270,9 +270,7 @@ func TestNotifyClusterPluginEvent(t *testing.T) { path, _ := fileutils.FindDir("tests") tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin.tar.gz")) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) testCluster.ClearMessages() @@ -331,9 +329,7 @@ func TestNotifyClusterPluginEvent(t *testing.T) { func TestDisableOnRemove(t *testing.T) { path, _ := fileutils.FindDir("tests") tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin.tar.gz")) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) testCases := []struct { Description string @@ -432,6 +428,324 @@ func TestDisableOnRemove(t *testing.T) { } } +func TestGetMarketplacePlugins(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableUploads = true + *cfg.PluginSettings.EnableMarketplace = false + }) + + t.Run("marketplace disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = false + *cfg.PluginSettings.MarketplaceUrl = "invalid.com" + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNotImplementedStatus(t, resp) + require.Nil(t, plugins) + }) + + t.Run("no server", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = "invalid.com" + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckInternalErrorStatus(t, resp) + require.Nil(t, plugins) + }) + + t.Run("no permission", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = "invalid.com" + }) + + plugins, resp := th.Client.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckForbiddenStatus(t, resp) + require.Nil(t, plugins) + }) + + t.Run("empty response from server", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + json, err := json.Marshal([]*model.MarketplacePlugin{}) + require.NoError(t, err) + res.Write(json) + })) + defer func() { testServer.Close() }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = testServer.URL + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Len(t, plugins, 0) + }) + + t.Run("verify server version is passed through", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + serverVersion, ok := req.URL.Query()["server_version"] + require.True(t, ok) + require.Len(t, serverVersion, 1) + require.Equal(t, model.CurrentVersion, serverVersion[0]) + require.NotEqual(t, 0, len(serverVersion[0])) + + res.WriteHeader(http.StatusOK) + json, err := json.Marshal([]*model.MarketplacePlugin{}) + require.NoError(t, err) + res.Write(json) + })) + defer func() { testServer.Close() }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = testServer.URL + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Len(t, plugins, 0) + }) +} + +func TestGetInstalledMarketplacePlugins(t *testing.T) { + samplePlugins := []*model.MarketplacePlugin{ + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-nps", + IconData: "http://example.com/icon.svg", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-nps/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz", + Manifest: &model.Manifest{ + Id: "com.mattermost.nps", + Name: "User Satisfaction Surveys", + Description: "This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost.", + Version: "1.0.3", + MinServerVersion: "5.14.0", + }, + }, + InstalledVersion: "", + }, + } + + path, _ := fileutils.FindDir("tests") + tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin.tar.gz")) + require.NoError(t, err) + + t.Run("marketplace client returns not-installed plugin", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + json, err := json.Marshal(samplePlugins) + require.NoError(t, err) + res.Write(json) + })) + defer func() { testServer.Close() }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableUploads = true + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = testServer.URL + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, samplePlugins, plugins) + + manifest, resp := th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarData)) + CheckNoError(t, resp) + + expectedPlugins := append(samplePlugins, &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "", + IconData: "", + DownloadURL: "", + Manifest: manifest, + }, + InstalledVersion: manifest.Version, + }) + sort.SliceStable(expectedPlugins, func(i, j int) bool { + return strings.ToLower(expectedPlugins[i].Manifest.Name) < strings.ToLower(expectedPlugins[j].Manifest.Name) + }) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, expectedPlugins, plugins) + + ok, resp := th.SystemAdminClient.RemovePlugin(manifest.Id) + CheckNoError(t, resp) + assert.True(t, ok) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, samplePlugins, plugins) + }) + + t.Run("marketplace client returns installed plugin", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableUploads = true + *cfg.PluginSettings.EnableMarketplace = true + }) + + manifest, resp := th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarData)) + CheckNoError(t, resp) + + newPlugin := &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "HomepageURL", + IconData: "IconData", + DownloadURL: "DownloadURL", + Manifest: manifest, + }, + InstalledVersion: manifest.Version, + } + expectedPlugins := append(samplePlugins, newPlugin) + sort.SliceStable(expectedPlugins, func(i, j int) bool { + return strings.ToLower(expectedPlugins[i].Manifest.Name) < strings.ToLower(expectedPlugins[j].Manifest.Name) + }) + + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + json, err := json.Marshal([]*model.MarketplacePlugin{samplePlugins[0], newPlugin}) + require.NoError(t, err) + res.Write(json) + })) + defer func() { testServer.Close() }() + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.MarketplaceUrl = testServer.URL + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, expectedPlugins, plugins) + + ok, resp := th.SystemAdminClient.RemovePlugin(manifest.Id) + CheckNoError(t, resp) + assert.True(t, ok) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + newPlugin.InstalledVersion = manifest.Version + require.Equal(t, expectedPlugins, plugins) + }) +} + +func TestSearchGetMarketplacePlugins(t *testing.T) { + samplePlugins := []*model.MarketplacePlugin{ + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://github.com/mattermost/mattermost-plugin-nps", + IconData: "Cjxzdmcgdmlld0JveD0nMCAwIDEwNSA5MycgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz4KPHBhdGggZD0nTTY2LDBoMzl2OTN6TTM4LDBoLTM4djkzek01MiwzNWwyNSw1OGgtMTZsLTgtMThoLTE4eicgZmlsbD0nI0VEMUMyNCcvPgo8L3N2Zz4K", + DownloadURL: "https://github.com/mattermost/mattermost-plugin-nps/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz", + Manifest: &model.Manifest{ + Id: "com.mattermost.nps", + Name: "User Satisfaction Surveys", + Description: "This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost.", + Version: "1.0.3", + MinServerVersion: "5.14.0", + }, + }, + InstalledVersion: "", + }, + } + + path, _ := fileutils.FindDir("tests") + tarData, err := ioutil.ReadFile(filepath.Join(path, "testplugin.tar.gz")) + require.NoError(t, err) + + tarDataV2, err := ioutil.ReadFile(filepath.Join(path, "testpluginv2.tar.gz")) + require.NoError(t, err) + + t.Run("search installed plugin", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + json, err := json.Marshal(samplePlugins) + require.NoError(t, err) + res.Write(json) + })) + defer func() { testServer.Close() }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableUploads = true + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.MarketplaceUrl = testServer.URL + }) + + plugins, resp := th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, samplePlugins, plugins) + + manifest, resp := th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarData)) + CheckNoError(t, resp) + + newPluginV1 := &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "", + IconData: "", + DownloadURL: "", + Manifest: manifest, + }, + InstalledVersion: manifest.Version, + } + expectedPlugins := append(samplePlugins, newPluginV1) + + manifest, resp = th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarDataV2)) + CheckNoError(t, resp) + newPluginV2 := &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "", + IconData: "", + DownloadURL: "", + Manifest: manifest, + }, + InstalledVersion: manifest.Version, + } + expectedPlugins = append(expectedPlugins, newPluginV2) + sort.SliceStable(expectedPlugins, func(i, j int) bool { + return strings.ToLower(expectedPlugins[i].Manifest.Name) < strings.ToLower(expectedPlugins[j].Manifest.Name) + }) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{}) + CheckNoError(t, resp) + require.Equal(t, expectedPlugins, plugins) + + // Search for plugins from the server + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{Filter: "testplugin_v2"}) + CheckNoError(t, resp) + require.Equal(t, []*model.MarketplacePlugin{newPluginV2}, plugins) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{Filter: "dsgsdg_v2"}) + CheckNoError(t, resp) + require.Equal(t, []*model.MarketplacePlugin{newPluginV2}, plugins) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{Filter: "User Satisfaction Surveys"}) + CheckNoError(t, resp) + require.Equal(t, samplePlugins, plugins) + + plugins, resp = th.SystemAdminClient.GetMarketplacePlugins(&model.MarketplacePluginFilter{Filter: "NOFILTER"}) + CheckNoError(t, resp) + require.Nil(t, plugins) + }) +} + func findClusterMessages(event string, msgs []*model.ClusterMessage) []*model.ClusterMessage { var result []*model.ClusterMessage for _, msg := range msgs { diff --git a/app/diagnostics.go b/app/diagnostics.go index 98a8f4dece..d5541b5814 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -602,6 +602,8 @@ func (a *App) trackConfig() { "enable_uploads": *cfg.PluginSettings.EnableUploads, "allow_insecure_download_url": *cfg.PluginSettings.AllowInsecureDownloadUrl, "enable_health_check": *cfg.PluginSettings.EnableHealthCheck, + "enable_marketplace": *cfg.PluginSettings.EnableMarketplace, + "is_default_marketplace_url": isDefault(*cfg.PluginSettings.MarketplaceUrl, model.PLUGIN_SETTINGS_DEFAULT_MARKETPLACE_URL), }) a.SendDiagnostic(TRACK_CONFIG_DATA_RETENTION, map[string]interface{}{ diff --git a/app/plugin.go b/app/plugin.go index 2a49a2ecb9..c1d67a44fc 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -7,12 +7,14 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" "github.com/mattermost/mattermost-server/services/filesstore" + "github.com/mattermost/mattermost-server/services/marketplace" "github.com/mattermost/mattermost-server/utils/fileutils" "github.com/pkg/errors" ) @@ -400,6 +402,103 @@ func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) { return resp, nil } +// GetMarketplacePlugins returns a list of plugins from the marketplace-server, +// and plugins that are installed locally. +func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*model.MarketplacePlugin, *model.AppError) { + var result []*model.MarketplacePlugin + pluginSet := map[string]bool{} + pluginsEnvironment := a.GetPluginsEnvironment() + if pluginsEnvironment == nil { + return nil, model.NewAppError("GetMarketplacePlugins", "app.plugin.config.app_error", nil, "", http.StatusInternalServerError) + } + + marketplaceClient, err := marketplace.NewClient( + *a.Config().PluginSettings.MarketplaceUrl, + a.HTTPService, + ) + if err != nil { + return nil, model.NewAppError("GetMarketplacePlugins", "app.plugin.marketplace_client.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // Fetch all plugins from marketplace. + marketplacePlugins, err := marketplaceClient.GetPlugins(&model.MarketplacePluginFilter{ + PerPage: -1, + ServerVersion: model.CurrentVersion, + }) + if err != nil { + return nil, model.NewAppError("GetMarketplacePlugins", "app.plugin.marketplace_plugins.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, p := range marketplacePlugins { + if p.Manifest == nil || !pluginMatchesFilter(p.Manifest, filter.Filter) { + continue + } + + marketplacePlugin := &model.MarketplacePlugin{ + BaseMarketplacePlugin: p, + } + + var manifest *model.Manifest + if manifest, err = pluginsEnvironment.GetManifest(p.Manifest.Id); err != nil && err != plugin.ErrNotFound { + return nil, model.NewAppError("GetMarketplacePlugins", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } else if err == nil { + // Plugin is installed. + marketplacePlugin.InstalledVersion = manifest.Version + } + + pluginSet[p.Manifest.Id] = true + result = append(result, marketplacePlugin) + } + + // Include all other installed plugins. + plugins, err := pluginsEnvironment.Available() + if err != nil { + return nil, model.NewAppError("GetMarketplacePlugins", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, plugin := range plugins { + if plugin.Manifest == nil || pluginSet[plugin.Manifest.Id] || !pluginMatchesFilter(plugin.Manifest, filter.Filter) { + continue + } + + result = append(result, &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + Manifest: plugin.Manifest, + }, + InstalledVersion: plugin.Manifest.Version, + }) + } + + // Sort result alphabetically. + sort.SliceStable(result, func(i, j int) bool { + return strings.ToLower(result[i].Manifest.Name) < strings.ToLower(result[j].Manifest.Name) + }) + + return result, nil +} + +func pluginMatchesFilter(manifest *model.Manifest, filter string) bool { + filter = strings.TrimSpace(strings.ToLower(filter)) + + if filter == "" { + return true + } + + if strings.ToLower(manifest.Id) == filter { + return true + } + + if strings.Contains(strings.ToLower(manifest.Name), filter) { + return true + } + + if strings.Contains(strings.ToLower(manifest.Description), filter) { + return true + } + + return false +} + // notifyPluginEnabled notifies connected websocket clients across all peers if the version of the given // plugin is same across them. // diff --git a/config/client.go b/config/client.go index a5a7a8c62c..3c1eca98cb 100644 --- a/config/client.go +++ b/config/client.go @@ -43,6 +43,7 @@ func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.L props["ExperimentalEnableDefaultChannelLeaveJoinMessages"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableDefaultChannelLeaveJoinMessages) props["ExperimentalGroupUnreadChannels"] = *c.ServiceSettings.ExperimentalGroupUnreadChannels props["EnableSVGs"] = strconv.FormatBool(*c.ServiceSettings.EnableSVGs) + props["EnableMarketplace"] = strconv.FormatBool(*c.PluginSettings.EnableMarketplace) // This setting is only temporary, so keep using the old setting name for the mobile and web apps props["ExperimentalEnablePostMetadata"] = "true" diff --git a/i18n/en.json b/i18n/en.json index 2376e75418..778ce46cf3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3506,6 +3506,22 @@ "id": "app.plugin.manifest.app_error", "translation": "Unable to find manifest for extracted plugin" }, + { + "id": "app.plugin.marketplace_client.app_error", + "translation": "Failed to create marketplace client." + }, + { + "id": "app.plugin.marketplace_disabled.app_error", + "translation": "Marketplace has been disabled. Please check your logs for details." + }, + { + "id": "app.plugin.marketplace_plugins.app_error", + "translation": "Failed to get plugins from the marketplace server." + }, + { + "id": "app.plugin.marshal.app_error", + "translation": "Failed to marshal marketplace plugins." + }, { "id": "app.plugin.mvdir.app_error", "translation": "Unable to move plugin from temporary directory to final destination. Another plugin may be using the same directory name." diff --git a/model/client4.go b/model/client4.go index aac5fd4bf3..2dffa31291 100644 --- a/model/client4.go +++ b/model/client4.go @@ -4519,6 +4519,31 @@ func (c *Client4) DisablePlugin(id string) (bool, *Response) { return CheckStatusOK(r), BuildResponse(r) } +// GetMarketplacePlugins will return a list of plugins that an admin can install. +// WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE. +func (c *Client4) GetMarketplacePlugins(filter *MarketplacePluginFilter) ([]*MarketplacePlugin, *Response) { + route := c.GetPluginsRoute() + "/marketplace" + u, parseErr := url.Parse(route) + if parseErr != nil { + return nil, &Response{Error: NewAppError("GetMarketplacePlugins", "model.client.parse_plugins.app_error", nil, parseErr.Error(), http.StatusBadRequest)} + } + + filter.ApplyToURL(u) + + r, err := c.DoApiGet(u.String(), "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + plugins, readerErr := MarketplacePluginsFromReader(r.Body) + if readerErr != nil { + return nil, BuildErrorResponse(r, NewAppError(route, "model.client.parse_plugins.app_error", nil, err.Error(), http.StatusBadRequest)) + } + + return plugins, BuildResponse(r) +} + // UpdateChannelScheme will update a channel's scheme. func (c *Client4) UpdateChannelScheme(channelId, schemeId string) (bool, *Response) { sip := &SchemeIDPatch{SchemeID: &schemeId} diff --git a/model/config.go b/model/config.go index 740c92ccf3..378933abc7 100644 --- a/model/config.go +++ b/model/config.go @@ -171,8 +171,10 @@ const ( DATA_RETENTION_SETTINGS_DEFAULT_FILE_RETENTION_DAYS = 365 DATA_RETENTION_SETTINGS_DEFAULT_DELETION_JOB_START_TIME = "02:00" - PLUGIN_SETTINGS_DEFAULT_DIRECTORY = "./plugins" - PLUGIN_SETTINGS_DEFAULT_CLIENT_DIRECTORY = "./client/plugins" + PLUGIN_SETTINGS_DEFAULT_DIRECTORY = "./plugins" + PLUGIN_SETTINGS_DEFAULT_CLIENT_DIRECTORY = "./client/plugins" + PLUGIN_SETTINGS_DEFAULT_ENABLE_MARKETPLACE = true + PLUGIN_SETTINGS_DEFAULT_MARKETPLACE_URL = "https://marketplace.integrations.mattermost.com" COMPLIANCE_EXPORT_TYPE_CSV = "csv" COMPLIANCE_EXPORT_TYPE_ACTIANCE = "actiance" @@ -2201,6 +2203,8 @@ type PluginSettings struct { ClientDirectory *string `restricted:"true"` Plugins map[string]map[string]interface{} PluginStates map[string]*PluginState + EnableMarketplace *bool + MarketplaceUrl *string } func (s *PluginSettings) SetDefaults(ls LogSettings) { @@ -2220,22 +2224,14 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) { s.EnableHealthCheck = NewBool(true) } - if s.Directory == nil { + if s.Directory == nil || *s.Directory == "" { s.Directory = NewString(PLUGIN_SETTINGS_DEFAULT_DIRECTORY) } - if *s.Directory == "" { - *s.Directory = PLUGIN_SETTINGS_DEFAULT_DIRECTORY - } - - if s.ClientDirectory == nil { + if s.ClientDirectory == nil || *s.ClientDirectory == "" { s.ClientDirectory = NewString(PLUGIN_SETTINGS_DEFAULT_CLIENT_DIRECTORY) } - if *s.ClientDirectory == "" { - *s.ClientDirectory = PLUGIN_SETTINGS_DEFAULT_CLIENT_DIRECTORY - } - if s.Plugins == nil { s.Plugins = make(map[string]map[string]interface{}) } @@ -2248,6 +2244,14 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) { // Enable the NPS plugin by default if diagnostics are enabled s.PluginStates["com.mattermost.nps"] = &PluginState{Enable: ls.EnableDiagnostics == nil || *ls.EnableDiagnostics} } + + if s.EnableMarketplace == nil { + s.EnableMarketplace = NewBool(PLUGIN_SETTINGS_DEFAULT_ENABLE_MARKETPLACE) + } + + if s.MarketplaceUrl == nil || *s.MarketplaceUrl == "" { + s.MarketplaceUrl = NewString(PLUGIN_SETTINGS_DEFAULT_MARKETPLACE_URL) + } } type GlobalRelayMessageExportSettings struct { diff --git a/model/marketplace_plugin.go b/model/marketplace_plugin.go new file mode 100644 index 0000000000..154bdde00d --- /dev/null +++ b/model/marketplace_plugin.go @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "net/url" + "strconv" +) + +// BaseMarketplacePlugin is a Mattermost plugin received from the marketplace server. +type BaseMarketplacePlugin struct { + HomepageURL string `json:"homepage_url"` + DownloadURL string `json:"download_url"` + IconData string `json:"icon_data"` + Manifest *Manifest `json:"manifest"` +} + +// MarketplacePlugin is a state aware marketplace plugin. +type MarketplacePlugin struct { + *BaseMarketplacePlugin + InstalledVersion string `json:"installed_version"` +} + +// BaseMarketplacePluginsFromReader decodes a json-encoded list of plugins from the given io.Reader. +func BaseMarketplacePluginsFromReader(reader io.Reader) ([]*BaseMarketplacePlugin, error) { + plugins := []*BaseMarketplacePlugin{} + decoder := json.NewDecoder(reader) + + if err := decoder.Decode(&plugins); err != nil && err != io.EOF { + return nil, err + } + + return plugins, nil +} + +// MarketplacePluginsFromReader decodes a json-encoded list of plugins from the given io.Reader. +func MarketplacePluginsFromReader(reader io.Reader) ([]*MarketplacePlugin, error) { + plugins := []*MarketplacePlugin{} + decoder := json.NewDecoder(reader) + + if err := decoder.Decode(&plugins); err != nil && err != io.EOF { + return nil, err + } + + return plugins, nil +} + +// MarketplacePluginFilter describes the parameters to request a list of plugins. +type MarketplacePluginFilter struct { + Page int + PerPage int + Filter string + ServerVersion string +} + +// ApplyToURL modifies the given url to include query string parameters for the request. +func (filter *MarketplacePluginFilter) ApplyToURL(u *url.URL) { + q := u.Query() + q.Add("page", strconv.Itoa(filter.Page)) + if filter.PerPage > 0 { + q.Add("per_page", strconv.Itoa(filter.PerPage)) + } + q.Add("filter", filter.Filter) + q.Add("server_version", filter.ServerVersion) + u.RawQuery = q.Encode() +} diff --git a/plugin/environment.go b/plugin/environment.go index e4410cf26a..3a9cdf7ab0 100644 --- a/plugin/environment.go +++ b/plugin/environment.go @@ -18,6 +18,8 @@ import ( "github.com/pkg/errors" ) +var ErrNotFound = errors.New("Item not found") + type apiImplCreatorFunc func(*model.Manifest) API // registeredPlugin stores the state for a given plugin that has been activated @@ -162,6 +164,23 @@ func (env *Environment) Statuses() (model.PluginStatuses, error) { return pluginStatuses, nil } +// GetManifest returns a manifest for a given pluginId. +// Returns ErrNotFound if plugin is not found. +func (env *Environment) GetManifest(pluginId string) (*model.Manifest, error) { + plugins, err := env.Available() + if err != nil { + return nil, errors.Wrap(err, "unable to get plugin statuses") + } + + for _, plugin := range plugins { + if plugin.Manifest != nil && plugin.Manifest.Id == pluginId { + return plugin.Manifest, nil + } + } + + return nil, ErrNotFound +} + func (env *Environment) Activate(id string) (manifest *model.Manifest, activated bool, reterr error) { // Check if we are already active if env.IsActive(id) { diff --git a/services/marketplace/client.go b/services/marketplace/client.go new file mode 100644 index 0000000000..9b03dd8c50 --- /dev/null +++ b/services/marketplace/client.go @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package marketplace + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/services/httpservice" + "github.com/pkg/errors" +) + +// Client is the programmatic interface to the marketplace server API. +type Client struct { + address string + httpClient *http.Client +} + +// NewClient creates a client to the marketplace server at the given address. +func NewClient(address string, httpService httpservice.HTTPService) (*Client, error) { + var httpClient *http.Client + addressUrl, err := url.Parse(address) + if err != nil { + return nil, errors.Wrap(err, "failed to parse marketplace address") + } + if addressUrl.Hostname() == "localhost" || addressUrl.Hostname() == "127.0.0.1" { + httpClient = httpService.MakeClient(true) + } else { + httpClient = httpService.MakeClient(false) + } + + return &Client{ + address: address, + httpClient: httpClient, + }, nil +} + +// GetPlugins fetches the list of plugins from the configured server. +func (c *Client) GetPlugins(request *model.MarketplacePluginFilter) ([]*model.BaseMarketplacePlugin, error) { + u, err := url.Parse(c.buildURL("/api/v1/plugins")) + if err != nil { + return nil, err + } + + request.ApplyToURL(u) + + resp, err := c.doGet(u.String()) + if err != nil { + return nil, err + } + defer closeBody(resp) + + switch resp.StatusCode { + case http.StatusOK: + return model.BaseMarketplacePluginsFromReader(resp.Body) + default: + return nil, errors.Errorf("failed with status code %d", resp.StatusCode) + } +} + +// closeBody ensures the Body of an http.Response is properly closed. +func closeBody(r *http.Response) { + if r.Body != nil { + _, _ = ioutil.ReadAll(r.Body) + _ = r.Body.Close() + } +} + +func (c *Client) buildURL(urlPath string, args ...interface{}) string { + return fmt.Sprintf("%s%s", c.address, fmt.Sprintf(urlPath, args...)) +} + +func (c *Client) doGet(u string) (*http.Response, error) { + return c.httpClient.Get(u) +} diff --git a/tests/testpluginv2.tar.gz b/tests/testpluginv2.tar.gz new file mode 100644 index 0000000000..1e0693fb51 Binary files /dev/null and b/tests/testpluginv2.tar.gz differ