[MM-16376] Allow server to download and install a plugin from… (#11372)
* Initial implementation of plugin remote source * Implement API route * Test API route * Add i18n * Handle different error cases in API route * Include missing i18n translation * Include AllowInsecureDownloadUrl in telemetry capture * Updates from PR feedback * Use HTTPService instead of http.Get * Remove InstallPluginFromUrlForced from client4 * Use net/url library to inspect url scheme * remove PluginDownloadUrl from web/params.go * Allow plugin downloads from internal sources
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
44b9fe3110
Коммит
8cdf5ffe67
@@ -7,6 +7,7 @@ package api4
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost-server/mlog"
|
"github.com/mattermost/mattermost-server/mlog"
|
||||||
"github.com/mattermost/mattermost-server/model"
|
"github.com/mattermost/mattermost-server/model"
|
||||||
@@ -22,6 +23,7 @@ func (api *API) InitPlugin() {
|
|||||||
api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(uploadPlugin)).Methods("POST")
|
api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(uploadPlugin)).Methods("POST")
|
||||||
api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(getPlugins)).Methods("GET")
|
api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(getPlugins)).Methods("GET")
|
||||||
api.BaseRoutes.Plugin.Handle("", api.ApiSessionRequired(removePlugin)).Methods("DELETE")
|
api.BaseRoutes.Plugin.Handle("", api.ApiSessionRequired(removePlugin)).Methods("DELETE")
|
||||||
|
api.BaseRoutes.Plugins.Handle("/install_from_url", api.ApiSessionRequired(installPluginFromUrl)).Methods("POST")
|
||||||
|
|
||||||
api.BaseRoutes.Plugins.Handle("/statuses", api.ApiSessionRequired(getPluginStatuses)).Methods("GET")
|
api.BaseRoutes.Plugins.Handle("/statuses", api.ApiSessionRequired(getPluginStatuses)).Methods("GET")
|
||||||
api.BaseRoutes.Plugin.Handle("/enable", api.ApiSessionRequired(enablePlugin)).Methods("POST")
|
api.BaseRoutes.Plugin.Handle("/enable", api.ApiSessionRequired(enablePlugin)).Methods("POST")
|
||||||
@@ -81,6 +83,58 @@ func uploadPlugin(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte(manifest.ToJson()))
|
w.Write([]byte(manifest.ToJson()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installPluginFromUrl(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !*c.App.Config().PluginSettings.Enable {
|
||||||
|
c.Err = model.NewAppError("installPluginFromUrl", "app.plugin.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
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadUrl := r.URL.Query().Get("plugin_download_url")
|
||||||
|
|
||||||
|
if !model.IsValidHttpUrl(downloadUrl) {
|
||||||
|
c.Err = model.NewAppError("installPluginFromUrl", "api.plugin.install.invalid_url.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.ParseRequestURI(downloadUrl)
|
||||||
|
if err != nil {
|
||||||
|
c.Err = model.NewAppError("installPluginFromUrl", "api.plugin.install.invalid_url.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*c.App.Config().PluginSettings.AllowInsecureDownloadUrl && u.Scheme != "https" {
|
||||||
|
c.Err = model.NewAppError("installPluginFromUrl", "api.plugin.install.insecure_url.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := c.App.HTTPService.MakeClient(true)
|
||||||
|
resp, err := client.Get(downloadUrl)
|
||||||
|
if err != nil {
|
||||||
|
c.Err = model.NewAppError("installPluginFromUrl", "api.plugin.install.download_failed.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
force := false
|
||||||
|
if r.URL.Query().Get("force") == "true" {
|
||||||
|
force = true
|
||||||
|
}
|
||||||
|
manifest, unpackErr := c.App.InstallPlugin(resp.Body, force)
|
||||||
|
|
||||||
|
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) {
|
func getPlugins(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
if !*c.App.Config().PluginSettings.Enable {
|
if !*c.App.Config().PluginSettings.Enable {
|
||||||
c.Err = model.NewAppError("getPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
|
c.Err = model.NewAppError("getPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -26,6 +28,7 @@ func TestPlugin(t *testing.T) {
|
|||||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
*cfg.PluginSettings.Enable = true
|
*cfg.PluginSettings.Enable = true
|
||||||
*cfg.PluginSettings.EnableUploads = true
|
*cfg.PluginSettings.EnableUploads = true
|
||||||
|
*cfg.PluginSettings.AllowInsecureDownloadUrl = true
|
||||||
})
|
})
|
||||||
|
|
||||||
path, _ := fileutils.FindDir("tests")
|
path, _ := fileutils.FindDir("tests")
|
||||||
@@ -34,9 +37,52 @@ func TestPlugin(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful upload
|
// Install from URL
|
||||||
manifest, resp := th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarData))
|
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
res.WriteHeader(http.StatusOK)
|
||||||
|
res.Write(tarData)
|
||||||
|
}))
|
||||||
|
defer func() { testServer.Close() }()
|
||||||
|
|
||||||
|
url := testServer.URL
|
||||||
|
|
||||||
|
manifest, resp := th.SystemAdminClient.InstallPluginFromUrl(url, false)
|
||||||
CheckNoError(t, resp)
|
CheckNoError(t, resp)
|
||||||
|
assert.Equal(t, "testplugin", manifest.Id)
|
||||||
|
|
||||||
|
_, resp = th.SystemAdminClient.InstallPluginFromUrl(url, false)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
|
||||||
|
manifest, resp = th.SystemAdminClient.InstallPluginFromUrl(url, true)
|
||||||
|
CheckNoError(t, resp)
|
||||||
|
assert.Equal(t, "testplugin", manifest.Id)
|
||||||
|
|
||||||
|
th.App.RemovePlugin(manifest.Id)
|
||||||
|
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
|
||||||
|
|
||||||
|
_, resp = th.SystemAdminClient.InstallPluginFromUrl(url, false)
|
||||||
|
CheckNotImplementedStatus(t, resp)
|
||||||
|
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
|
||||||
|
|
||||||
|
_, resp = th.Client.InstallPluginFromUrl(url, false)
|
||||||
|
CheckForbiddenStatus(t, resp)
|
||||||
|
|
||||||
|
_, resp = th.SystemAdminClient.InstallPluginFromUrl("http://nodata", false)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.AllowInsecureDownloadUrl = false })
|
||||||
|
|
||||||
|
_, resp = th.SystemAdminClient.InstallPluginFromUrl(url, false)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
|
||||||
|
// Successful upload
|
||||||
|
manifest, resp = th.SystemAdminClient.UploadPlugin(bytes.NewReader(tarData))
|
||||||
|
CheckNoError(t, resp)
|
||||||
|
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableUploads = true })
|
||||||
|
|
||||||
manifest, resp = th.SystemAdminClient.UploadPluginForced(bytes.NewReader(tarData))
|
manifest, resp = th.SystemAdminClient.UploadPluginForced(bytes.NewReader(tarData))
|
||||||
defer os.RemoveAll("plugins/testplugin")
|
defer os.RemoveAll("plugins/testplugin")
|
||||||
CheckNoError(t, resp)
|
CheckNoError(t, resp)
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ func (a *App) trackConfig() {
|
|||||||
"enable_zoom": pluginActivated(cfg.PluginSettings.PluginStates, "zoom"),
|
"enable_zoom": pluginActivated(cfg.PluginSettings.PluginStates, "zoom"),
|
||||||
"enable": *cfg.PluginSettings.Enable,
|
"enable": *cfg.PluginSettings.Enable,
|
||||||
"enable_uploads": *cfg.PluginSettings.EnableUploads,
|
"enable_uploads": *cfg.PluginSettings.EnableUploads,
|
||||||
|
"allow_insecure_download_url": *cfg.PluginSettings.AllowInsecureDownloadUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
a.SendDiagnostic(TRACK_CONFIG_DATA_RETENTION, map[string]interface{}{
|
a.SendDiagnostic(TRACK_CONFIG_DATA_RETENTION, map[string]interface{}{
|
||||||
|
|||||||
12
i18n/en.json
12
i18n/en.json
@@ -1464,6 +1464,18 @@
|
|||||||
"id": "api.outgoing_webhook.disabled.app_error",
|
"id": "api.outgoing_webhook.disabled.app_error",
|
||||||
"translation": "Outgoing webhooks have been disabled by the system admin."
|
"translation": "Outgoing webhooks have been disabled by the system admin."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "api.plugin.install.download_failed.app_error",
|
||||||
|
"translation": "An error occurred while downloading the plugin."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api.plugin.install.insecure_url.app_error",
|
||||||
|
"translation": "An insecure url was given to download the plugin. Please provide a secure url or enable PluginSettings.AllowInsecureDownloadUrl in your configuration."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api.plugin.install.invalid_url.app_error",
|
||||||
|
"translation": "An invalid url was given to download the plugin."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "api.plugin.upload.array.app_error",
|
"id": "api.plugin.upload.array.app_error",
|
||||||
"translation": "File array is empty in multipart/form request"
|
"translation": "File array is empty in multipart/form request"
|
||||||
|
|||||||
@@ -4263,6 +4263,21 @@ func (c *Client4) uploadPlugin(file io.Reader, force bool) (*Manifest, *Response
|
|||||||
return ManifestFromJson(rp.Body), BuildResponse(rp)
|
return ManifestFromJson(rp.Body), BuildResponse(rp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client4) InstallPluginFromUrl(downloadUrl string, force bool) (*Manifest, *Response) {
|
||||||
|
forceStr := "false"
|
||||||
|
if force {
|
||||||
|
forceStr = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s?plugin_download_url=%s&force=%s", c.GetPluginsRoute()+"/install_from_url", url.QueryEscape(downloadUrl), forceStr)
|
||||||
|
r, err := c.DoApiPost(url, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, BuildErrorResponse(r, err)
|
||||||
|
}
|
||||||
|
defer closeBody(r)
|
||||||
|
return ManifestFromJson(r.Body), BuildResponse(r)
|
||||||
|
}
|
||||||
|
|
||||||
// GetPlugins will return a list of plugin manifests for currently active plugins.
|
// GetPlugins will return a list of plugin manifests for currently active plugins.
|
||||||
// WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE.
|
// WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE.
|
||||||
func (c *Client4) GetPlugins() (*PluginsResponse, *Response) {
|
func (c *Client4) GetPlugins() (*PluginsResponse, *Response) {
|
||||||
|
|||||||
@@ -2183,12 +2183,13 @@ type PluginState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PluginSettings struct {
|
type PluginSettings struct {
|
||||||
Enable *bool
|
Enable *bool
|
||||||
EnableUploads *bool `restricted:"true"`
|
EnableUploads *bool `restricted:"true"`
|
||||||
Directory *string `restricted:"true"`
|
AllowInsecureDownloadUrl *bool `restricted:"true"`
|
||||||
ClientDirectory *string `restricted:"true"`
|
Directory *string `restricted:"true"`
|
||||||
Plugins map[string]map[string]interface{}
|
ClientDirectory *string `restricted:"true"`
|
||||||
PluginStates map[string]*PluginState
|
Plugins map[string]map[string]interface{}
|
||||||
|
PluginStates map[string]*PluginState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PluginSettings) SetDefaults(ls LogSettings) {
|
func (s *PluginSettings) SetDefaults(ls LogSettings) {
|
||||||
@@ -2200,6 +2201,10 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) {
|
|||||||
s.EnableUploads = NewBool(false)
|
s.EnableUploads = NewBool(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.AllowInsecureDownloadUrl == nil {
|
||||||
|
s.AllowInsecureDownloadUrl = NewBool(false)
|
||||||
|
}
|
||||||
|
|
||||||
if s.Directory == nil {
|
if s.Directory == nil {
|
||||||
s.Directory = NewString(PLUGIN_SETTINGS_DEFAULT_DIRECTORY)
|
s.Directory = NewString(PLUGIN_SETTINGS_DEFAULT_DIRECTORY)
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user