diff --git a/api4/api.go b/api4/api.go index f824c5cc0a..5ea06a155f 100644 --- a/api4/api.go +++ b/api4/api.go @@ -231,6 +231,7 @@ func Init(a *app.App, root *mux.Router) *API { api.InitScheme() api.InitImage() api.InitTermsOfService() + api.InitAction() root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/api4/command_test.go b/api4/command_test.go index 5c12f29005..cffedd1d81 100644 --- a/api4/command_test.go +++ b/api4/command_test.go @@ -9,6 +9,7 @@ import ( "net/url" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/model" @@ -508,7 +509,9 @@ func TestExecuteGetCommand(t *testing.T) { commandResponse, resp := Client.ExecuteCommand(channel.Id, "/getcommand") CheckNoError(t, resp) + assert.True(t, len(commandResponse.TriggerId) == 26) + expectedCommandResponse.TriggerId = commandResponse.TriggerId expectedCommandResponse.Props["from_webhook"] = "true" require.Equal(t, expectedCommandResponse, commandResponse) } @@ -566,7 +569,9 @@ func TestExecutePostCommand(t *testing.T) { commandResponse, resp := Client.ExecuteCommand(channel.Id, "/postcommand") CheckNoError(t, resp) + assert.True(t, len(commandResponse.TriggerId) == 26) + expectedCommandResponse.TriggerId = commandResponse.TriggerId expectedCommandResponse.Props["from_webhook"] = "true" require.Equal(t, expectedCommandResponse, commandResponse) diff --git a/api4/integration_action.go b/api4/integration_action.go new file mode 100644 index 0000000000..c2eb00dbac --- /dev/null +++ b/api4/integration_action.go @@ -0,0 +1,105 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/model" +) + +func (api *API) InitAction() { + api.BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9]+}", api.ApiSessionRequired(doPostAction)).Methods("POST") + + api.BaseRoutes.ApiRoot.Handle("/actions/dialogs/open", api.ApiHandler(openDialog)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/actions/dialogs/submit", api.ApiSessionRequired(submitDialog)).Methods("POST") +} + +func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePostId().RequireActionId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToChannelByPost(c.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + + actionRequest := model.DoPostActionRequestFromJson(r.Body) + if actionRequest == nil { + actionRequest = &model.DoPostActionRequest{} + } + + var err *model.AppError + resp := &model.PostActionAPIResponse{Status: "OK"} + + if resp.TriggerId, err = c.App.DoPostAction(c.Params.PostId, c.Params.ActionId, c.Session.UserId, actionRequest.SelectedOption); err != nil { + c.Err = err + return + } + + b, _ := json.Marshal(resp) + + w.Write(b) +} + +func openDialog(c *Context, w http.ResponseWriter, r *http.Request) { + var dialog model.OpenDialogRequest + err := json.NewDecoder(r.Body).Decode(&dialog) + if err != nil { + c.SetInvalidParam("dialog") + return + } + + if dialog.URL == "" { + c.SetInvalidParam("url") + return + } + + if err := c.App.OpenInteractiveDialog(dialog); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + +func submitDialog(c *Context, w http.ResponseWriter, r *http.Request) { + var submit model.SubmitDialogRequest + + jsonErr := json.NewDecoder(r.Body).Decode(&submit) + if jsonErr != nil { + c.SetInvalidParam("dialog") + return + } + + if submit.URL == "" { + c.SetInvalidParam("url") + return + } + + submit.UserId = c.Session.UserId + + if !c.App.SessionHasPermissionToChannel(c.Session, submit.ChannelId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + + if !c.App.SessionHasPermissionToTeam(c.Session, submit.TeamId, model.PERMISSION_VIEW_TEAM) { + c.SetPermissionError(model.PERMISSION_VIEW_TEAM) + return + } + + resp, err := c.App.SubmitInteractiveDialog(submit) + if err != nil { + c.Err = err + return + } + + b, _ := json.Marshal(resp) + + w.Write(b) +} diff --git a/api4/integration_action_test.go b/api4/integration_action_test.go new file mode 100644 index 0000000000..902e1715a9 --- /dev/null +++ b/api4/integration_action_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/require" +) + +func TestOpenDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + WebSocketClient, err := th.CreateWebSocketClient() + require.Nil(t, err) + + WebSocketClient.Listen() + + _, triggerId, err := model.GenerateTriggerId(th.BasicUser.Id, th.App.AsymmetricSigningKey()) + require.Nil(t, err) + + request := model.OpenDialogRequest{ + TriggerId: triggerId, + URL: "http://localhost:8065", + Dialog: model.Dialog{ + CallbackId: "callbackid", + Title: "Some Title", + Elements: []model.DialogElement{ + model.DialogElement{ + DisplayName: "Element Name", + Name: "element_name", + Type: "text", + Placeholder: "Enter a value", + }, + }, + SubmitLabel: "Submit", + NotifyOnCancel: false, + State: "somestate", + }, + } + + pass, resp := Client.OpenInteractiveDialog(request) + CheckNoError(t, resp) + assert.True(t, pass) + + timeout := time.After(300 * time.Millisecond) + waiting := true + for waiting { + select { + case event := <-WebSocketClient.EventChannel: + if event.Event == model.WEBSOCKET_EVENT_OPEN_DIALOG { + waiting = false + } + + case <-timeout: + waiting = false + t.Fatal("should have received open_dialog event") + } + } + + // Should fail on bad trigger ID + request.TriggerId = "junk" + pass, resp = Client.OpenInteractiveDialog(request) + CheckBadRequestStatus(t, resp) + assert.False(t, pass) + + // URL is required + request.TriggerId = triggerId + request.URL = "" + pass, resp = Client.OpenInteractiveDialog(request) + CheckBadRequestStatus(t, resp) + assert.False(t, pass) +} + +func TestSubmitDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + submit := model.SubmitDialogRequest{ + CallbackId: "callbackid", + State: "somestate", + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + Submission: map[string]interface{}{"somename": "somevalue"}, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request model.SubmitDialogRequest + err := json.NewDecoder(r.Body).Decode(&request) + require.Nil(t, err) + assert.NotNil(t, request) + + assert.Equal(t, request.URL, "") + assert.Equal(t, request.UserId, submit.UserId) + assert.Equal(t, request.ChannelId, submit.ChannelId) + assert.Equal(t, request.TeamId, submit.TeamId) + assert.Equal(t, request.CallbackId, submit.CallbackId) + assert.Equal(t, request.State, submit.State) + val, ok := request.Submission["somename"].(string) + require.True(t, ok) + assert.Equal(t, "somevalue", val) + })) + defer ts.Close() + + submit.URL = ts.URL + + submitResp, resp := Client.SubmitInteractiveDialog(submit) + CheckNoError(t, resp) + assert.NotNil(t, submitResp) + + submit.URL = "" + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckBadRequestStatus(t, resp) + assert.Nil(t, submitResp) + + submit.URL = ts.URL + submit.ChannelId = model.NewId() + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckForbiddenStatus(t, resp) + assert.Nil(t, submitResp) + + submit.URL = ts.URL + submit.ChannelId = th.BasicChannel.Id + submit.TeamId = model.NewId() + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckForbiddenStatus(t, resp) + assert.Nil(t, submitResp) +} diff --git a/api4/post.go b/api4/post.go index 7c116b7c77..02a269b15b 100644 --- a/api4/post.go +++ b/api4/post.go @@ -25,7 +25,6 @@ func (api *API) InitPost() { api.BaseRoutes.Team.Handle("/posts/search", api.ApiSessionRequired(searchPosts)).Methods("POST") api.BaseRoutes.Post.Handle("", api.ApiSessionRequired(updatePost)).Methods("PUT") api.BaseRoutes.Post.Handle("/patch", api.ApiSessionRequired(patchPost)).Methods("PUT") - api.BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9]+}", api.ApiSessionRequired(doPostAction)).Methods("POST") api.BaseRoutes.Post.Handle("/pin", api.ApiSessionRequired(pinPost)).Methods("POST") api.BaseRoutes.Post.Handle("/unpin", api.ApiSessionRequired(unpinPost)).Methods("POST") } @@ -529,27 +528,3 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set(model.HEADER_ETAG_SERVER, model.GetEtagForFileInfos(infos)) w.Write([]byte(model.FileInfosToJson(infos))) } - -func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { - c.RequirePostId().RequireActionId() - if c.Err != nil { - return - } - - if !c.App.SessionHasPermissionToChannelByPost(c.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { - c.SetPermissionError(model.PERMISSION_READ_CHANNEL) - return - } - - actionRequest := model.DoPostActionRequestFromJson(r.Body) - if actionRequest == nil { - actionRequest = &model.DoPostActionRequest{} - } - - if err := c.App.DoPostAction(c.Params.PostId, c.Params.ActionId, c.Session.UserId, actionRequest.SelectedOption); err != nil { - c.Err = err - return - } - - ReturnStatusOK(w) -} diff --git a/app/command.go b/app/command.go index b661913e80..490bc25ecb 100644 --- a/app/command.go +++ b/app/command.go @@ -162,6 +162,13 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * message := strings.Join(parts[1:], " ") provider := GetCommandProvider(trigger) + clientTriggerId, triggerId, appErr := model.GenerateTriggerId(args.UserId, a.AsymmetricSigningKey()) + if appErr != nil { + mlog.Error(appErr.Error()) + } + + args.TriggerId = triggerId + if provider != nil { if cmd := provider.GetCommand(a, args.T); cmd != nil { response := provider.DoCommand(a, args, message) @@ -174,6 +181,7 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * return nil, appErr } if cmd != nil { + response.TriggerId = clientTriggerId return a.HandleCommandResponse(cmd, args, response, true) } @@ -228,6 +236,8 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * p.Set("command", "/"+trigger) p.Set("text", message) + p.Set("trigger_id", triggerId) + hook, appErr := a.CreateCommandWebhook(cmd.Id, args) if appErr != nil { return nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]interface{}{"Trigger": trigger}, appErr.Error(), http.StatusInternalServerError) @@ -269,6 +279,9 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * if response == nil { return nil, model.NewAppError("command", "api.command.execute_command.failed_empty.app_error", map[string]interface{}{"Trigger": trigger}, "", http.StatusInternalServerError) } + + response.TriggerId = clientTriggerId + return a.HandleCommandResponse(cmd, args, response, false) } } diff --git a/app/integration_action.go b/app/integration_action.go new file mode 100644 index 0000000000..a8ea700279 --- /dev/null +++ b/app/integration_action.go @@ -0,0 +1,200 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +// Integration Action Flow +// +// 1. An integration creates an interactive message button or menu. +// 2. A user clicks on a button or selects an option from the menu. +// 3. The client sends a request to server to complete the post action, calling DoPostAction below. +// 4. DoPostAction will send an HTTP POST request to the integration containing contextual data, including +// an encoded and signed trigger ID. Slash commands also include trigger IDs in their payloads. +// 5. The integration performs any actions it needs to and optionally makes a request back to the MM server +// using the trigger ID to open an interactive dialog. +// 6. If that optional request is made, OpenInteractiveDialog sends a WebSocket event to all connected clients +// for the relevant user, telling them to display the dialog. +// 7. The user fills in the dialog and submits it, where SubmitInteractiveDialog will submit it back to the +// integration for handling. + +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/services/httpservice" + "github.com/mattermost/mattermost-server/utils" +) + +func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (string, *model.AppError) { + pchan := a.Srv.Store.Post().GetSingle(postId) + cchan := a.Srv.Store.Channel().GetForPost(postId) + + result := <-pchan + if result.Err != nil { + return "", result.Err + } + post := result.Data.(*model.Post) + + result = <-cchan + if result.Err != nil { + return "", result.Err + } + channel := result.Data.(*model.Channel) + + action := post.GetAction(actionId) + if action == nil || action.Integration == nil { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) + } + + request := &model.PostActionIntegrationRequest{ + UserId: userId, + ChannelId: post.ChannelId, + TeamId: channel.TeamId, + PostId: postId, + Type: action.Type, + Context: action.Integration.Context, + } + + clientTriggerId, _, err := request.GenerateTriggerId(a.AsymmetricSigningKey()) + if err != nil { + return "", err + } + + if action.Type == model.POST_ACTION_TYPE_SELECT { + request.DataSource = action.DataSource + request.Context["selected_option"] = selectedOption + } + + resp, err := a.DoActionRequest(action.Integration.URL, request.ToJson()) + if resp != nil { + defer consumeAndClose(resp) + } + if err != nil { + return "", err + } + + var response model.PostActionIntegrationResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + + retainedProps := []string{"override_username", "override_icon_url"} + + if response.Update != nil { + response.Update.Id = postId + response.Update.AddProp("from_webhook", "true") + for _, prop := range retainedProps { + if value, ok := post.Props[prop]; ok { + response.Update.Props[prop] = value + } else { + delete(response.Update.Props, prop) + } + } + if _, err := a.UpdatePost(response.Update, false); err != nil { + return "", err + } + } + + if response.EphemeralText != "" { + ephemeralPost := &model.Post{} + ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText) + ephemeralPost.ChannelId = post.ChannelId + ephemeralPost.RootId = post.RootId + if ephemeralPost.RootId == "" { + ephemeralPost.RootId = post.Id + } + ephemeralPost.UserId = post.UserId + ephemeralPost.AddProp("from_webhook", "true") + for _, prop := range retainedProps { + if value, ok := post.Props[prop]; ok { + ephemeralPost.Props[prop] = value + } else { + delete(ephemeralPost.Props, prop) + } + } + a.SendEphemeralPost(userId, ephemeralPost) + } + + return clientTriggerId, nil +} + +// Perform an HTTP POST request to an integration's action endpoint. +// Caller must consume and close returned http.Response as necessary. +func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { + req, _ := http.NewRequest("POST", rawURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Allow access to plugin routes for action buttons + var httpClient *httpservice.Client + url, _ := url.Parse(rawURL) + siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) + subpath, _ := utils.GetSubpathFromConfig(a.Config()) + if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) { + httpClient = a.HTTPService.MakeClient(true) + } else { + httpClient = a.HTTPService.MakeClient(false) + } + + resp, httpErr := httpClient.Do(req) + if httpErr != nil { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+httpErr.Error(), http.StatusBadRequest) + } + + if resp.StatusCode != http.StatusOK { + return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) + } + + return resp, nil +} + +func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { + clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) + if err != nil { + return err + } + + request.TriggerId = clientTriggerId + + jsonRequest, _ := json.Marshal(request) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_OPEN_DIALOG, "", "", userId, nil) + message.Add("dialog", string(jsonRequest)) + a.Publish(message) + + return nil +} + +func (a *App) SubmitInteractiveDialog(request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) { + url := request.URL + request.URL = "" + request.Type = "dialog_submission" + + b, jsonErr := json.Marshal(request) + if jsonErr != nil { + return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, jsonErr.Error(), http.StatusBadRequest) + } + + resp, err := a.DoActionRequest(url, b) + if resp != nil { + defer consumeAndClose(resp) + } + + if err != nil { + return nil, err + } + + var response model.SubmitDialogResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + // Don't fail, an empty response is acceptable + return &response, nil + } + + return &response, nil +} diff --git a/app/integration_action_test.go b/app/integration_action_test.go new file mode 100644 index 0000000000..070bf3519f --- /dev/null +++ b/app/integration_action_test.go @@ -0,0 +1,320 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" +) + +func TestPostAction(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := model.PostActionIntegrationRequestFromJson(r.Body) + assert.NotNil(t, request) + + assert.Equal(t, request.UserId, th.BasicUser.Id) + assert.Equal(t, request.ChannelId, th.BasicChannel.Id) + assert.Equal(t, request.TeamId, th.BasicTeam.Id) + assert.True(t, len(request.TriggerId) > 0) + if request.Type == model.POST_ACTION_TYPE_SELECT { + assert.Equal(t, request.DataSource, "some_source") + assert.Equal(t, request.Context["selected_option"], "selected") + } else { + assert.Equal(t, request.DataSource, "") + } + assert.Equal(t, "foo", request.Context["s"]) + assert.EqualValues(t, 3, request.Context["n"]) + fmt.Fprintf(w, `{"post": {"message": "updated"}, "ephemeral_text": "foo"}`) + })) + defer ts.Close() + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL, + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, false) + require.Nil(t, err) + + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + menuPost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL, + }, + Name: "action", + Type: model.POST_ACTION_TYPE_SELECT, + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + post2, err := th.App.CreatePostAsUser(&menuPost, false) + require.Nil(t, err) + + attachments2, ok := post2.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + require.NotEmpty(t, attachments2[0].Actions) + require.NotEmpty(t, attachments2[0].Actions[0].Id) + + clientTriggerId, err := th.App.DoPostAction(post.Id, "notavalidid", th.BasicUser.Id, "") + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + assert.True(t, clientTriggerId == "") + + clientTriggerId, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + assert.True(t, len(clientTriggerId) == 26) + + clientTriggerId, err = th.App.DoPostAction(post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected") + require.Nil(t, err) + assert.True(t, len(clientTriggerId) == 26) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + }) + + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + require.True(t, strings.Contains(err.Error(), "address forbidden")) + + interactivePostPlugin := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL + "/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postplugin, err := th.App.CreatePostAsUser(&interactivePostPlugin, false) + require.Nil(t, err) + + attachmentsPlugin, ok := postplugin.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.SiteURL = "http://127.1.1.1" + }) + + interactivePostSiteURL := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: "http://127.1.1.1/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postSiteURL, err := th.App.CreatePostAsUser(&interactivePostSiteURL, false) + require.Nil(t, err) + + attachmentsSiteURL, ok := postSiteURL.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + require.False(t, strings.Contains(err.Error(), "address forbidden")) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.SiteURL = ts.URL + "/subpath" + }) + + interactivePostSubpath := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL + "/subpath/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postSubpath, err := th.App.CreatePostAsUser(&interactivePostSubpath, false) + require.Nil(t, err) + + attachmentsSubpath, ok := postSubpath.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) +} + +func TestSubmitInteractiveDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + submit := model.SubmitDialogRequest{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + CallbackId: "someid", + State: "somestate", + Submission: map[string]interface{}{ + "name1": "value1", + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request model.SubmitDialogRequest + err := json.NewDecoder(r.Body).Decode(&request) + require.Nil(t, err) + assert.NotNil(t, request) + + assert.Equal(t, request.URL, "") + assert.Equal(t, request.UserId, submit.UserId) + assert.Equal(t, request.ChannelId, submit.ChannelId) + assert.Equal(t, request.TeamId, submit.TeamId) + assert.Equal(t, request.CallbackId, submit.CallbackId) + assert.Equal(t, request.State, submit.State) + val, ok := request.Submission["name1"].(string) + require.True(t, ok) + assert.Equal(t, "value1", val) + + resp := model.SubmitDialogResponse{ + Errors: map[string]string{"name1": "some error"}, + } + + b, _ := json.Marshal(resp) + + w.Write(b) + })) + defer ts.Close() + + submit.URL = ts.URL + + resp, err := th.App.SubmitInteractiveDialog(submit) + assert.Nil(t, err) + require.NotNil(t, resp) + assert.Equal(t, "some error", resp.Errors["name1"]) + + submit.URL = "" + resp, err = th.App.SubmitInteractiveDialog(submit) + assert.NotNil(t, err) + assert.Nil(t, resp) +} diff --git a/app/plugin_api.go b/app/plugin_api.go index 63d3b21211..78be7dbf46 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -495,6 +495,10 @@ func (api *PluginAPI) SetTeamIcon(teamId string, data []byte) *model.AppError { return nil } +func (api *PluginAPI) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + return api.app.OpenInteractiveDialog(dialog) +} + // Plugin Section func (api *PluginAPI) GetPlugins() ([]*model.Manifest, *model.AppError) { diff --git a/app/post.go b/app/post.go index 8f391325ce..c4909a9d42 100644 --- a/app/post.go +++ b/app/post.go @@ -7,12 +7,10 @@ import ( "crypto/hmac" "crypto/sha1" "encoding/hex" - "encoding/json" "fmt" "io" "net/http" "net/url" - "path" "strings" "github.com/dyatlov/go-opengraph/opengraph" @@ -21,7 +19,6 @@ import ( "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" - "github.com/mattermost/mattermost-server/services/httpservice" "github.com/mattermost/mattermost-server/store" "github.com/mattermost/mattermost-server/utils" ) @@ -862,111 +859,6 @@ func makeOpenGraphURLsAbsolute(og *opengraph.OpenGraph, requestURL string) { } } -func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) *model.AppError { - pchan := a.Srv.Store.Post().GetSingle(postId) - cchan := a.Srv.Store.Channel().GetForPost(postId) - - result := <-pchan - if result.Err != nil { - return result.Err - } - post := result.Data.(*model.Post) - - result = <-cchan - if result.Err != nil { - return result.Err - } - channel := result.Data.(*model.Channel) - - action := post.GetAction(actionId) - if action == nil || action.Integration == nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) - } - - request := &model.PostActionIntegrationRequest{ - UserId: userId, - ChannelId: post.ChannelId, - TeamId: channel.TeamId, - PostId: postId, - Type: action.Type, - Context: action.Integration.Context, - } - - if action.Type == model.POST_ACTION_TYPE_SELECT { - request.DataSource = action.DataSource - request.Context["selected_option"] = selectedOption - } - - req, _ := http.NewRequest("POST", action.Integration.URL, strings.NewReader(request.ToJson())) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Allow access to plugin routes for action buttons - var httpClient *httpservice.Client - url, _ := url.Parse(action.Integration.URL) - siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) - subpath, _ := utils.GetSubpathFromConfig(a.Config()) - if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) { - httpClient = a.HTTPService.MakeClient(true) - } else { - httpClient = a.HTTPService.MakeClient(false) - } - - resp, err := httpClient.Do(req) - if err != nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) - } - defer consumeAndClose(resp) - - if resp.StatusCode != http.StatusOK { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) - } - - var response model.PostActionIntegrationResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) - } - - retainedProps := []string{"override_username", "override_icon_url"} - - if response.Update != nil { - response.Update.Id = postId - response.Update.AddProp("from_webhook", "true") - for _, prop := range retainedProps { - if value, ok := post.Props[prop]; ok { - response.Update.Props[prop] = value - } else { - delete(response.Update.Props, prop) - } - } - if _, err := a.UpdatePost(response.Update, false); err != nil { - return err - } - } - - if response.EphemeralText != "" { - ephemeralPost := &model.Post{} - ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText) - ephemeralPost.ChannelId = post.ChannelId - ephemeralPost.RootId = post.RootId - if ephemeralPost.RootId == "" { - ephemeralPost.RootId = post.Id - } - ephemeralPost.UserId = post.UserId - ephemeralPost.AddProp("from_webhook", "true") - for _, prop := range retainedProps { - if value, ok := post.Props[prop]; ok { - ephemeralPost.Props[prop] = value - } else { - delete(ephemeralPost.Props, prop) - } - } - a.SendEphemeralPost(userId, ephemeralPost) - } - - return nil -} - func (a *App) PostListWithProxyAddedToImageURLs(list *model.PostList) *model.PostList { if f := a.ImageProxyAdder(); f != nil { return list.WithRewrittenImageURLs(f) diff --git a/app/post_test.go b/app/post_test.go index ed5cb76f2c..8a0e81c1bb 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -6,7 +6,6 @@ package app import ( "fmt" "net/http" - "net/http/httptest" "strings" "sync/atomic" "testing" @@ -120,246 +119,6 @@ func TestPostReplyToPostWhereRootPosterLeftChannel(t *testing.T) { } } -func TestPostAction(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" - }) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - request := model.PostActionIntegrationRequesteFromJson(r.Body) - assert.NotNil(t, request) - - assert.Equal(t, request.UserId, th.BasicUser.Id) - assert.Equal(t, request.ChannelId, th.BasicChannel.Id) - assert.Equal(t, request.TeamId, th.BasicTeam.Id) - if request.Type == model.POST_ACTION_TYPE_SELECT { - assert.Equal(t, request.DataSource, "some_source") - assert.Equal(t, request.Context["selected_option"], "selected") - } else { - assert.Equal(t, request.DataSource, "") - } - assert.Equal(t, "foo", request.Context["s"]) - assert.EqualValues(t, 3, request.Context["n"]) - fmt.Fprintf(w, `{"post": {"message": "updated"}, "ephemeral_text": "foo"}`) - })) - defer ts.Close() - - interactivePost := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL, - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - post, err := th.App.CreatePostAsUser(&interactivePost, false) - require.Nil(t, err) - - attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - require.NotEmpty(t, attachments[0].Actions) - require.NotEmpty(t, attachments[0].Actions[0].Id) - - menuPost := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL, - }, - Name: "action", - Type: model.POST_ACTION_TYPE_SELECT, - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - post2, err := th.App.CreatePostAsUser(&menuPost, false) - require.Nil(t, err) - - attachments2, ok := post2.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - require.NotEmpty(t, attachments2[0].Actions) - require.NotEmpty(t, attachments2[0].Actions[0].Id) - - err = th.App.DoPostAction(post.Id, "notavalidid", th.BasicUser.Id, "") - require.NotNil(t, err) - assert.Equal(t, http.StatusNotFound, err.StatusCode) - - err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) - - err = th.App.DoPostAction(post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected") - require.Nil(t, err) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" - }) - - err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.NotNil(t, err) - require.True(t, strings.Contains(err.Error(), "address forbidden")) - - interactivePostPlugin := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL + "/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postplugin, err := th.App.CreatePostAsUser(&interactivePostPlugin, false) - require.Nil(t, err) - - attachmentsPlugin, ok := postplugin.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.SiteURL = "http://127.1.1.1" - }) - - interactivePostSiteURL := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: "http://127.1.1.1/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postSiteURL, err := th.App.CreatePostAsUser(&interactivePostSiteURL, false) - require.Nil(t, err) - - attachmentsSiteURL, ok := postSiteURL.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "") - require.NotNil(t, err) - require.False(t, strings.Contains(err.Error(), "address forbidden")) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.SiteURL = ts.URL + "/subpath" - }) - - interactivePostSubpath := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL + "/subpath/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postSubpath, err := th.App.CreatePostAsUser(&interactivePostSubpath, false) - require.Nil(t, err) - - attachmentsSubpath, ok := postSubpath.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) -} - func TestPostChannelMentions(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() diff --git a/i18n/en.json b/i18n/en.json index 3f8dca0f01..3cfcf35326 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -7,6 +7,34 @@ "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." }, + { + "id": "app.submit_interactive_dialog.json_error", + "translation": "Encountered an error encoding JSON for the interactive dialog." + }, + { + "id": "interactive_message.generate_trigger_id.signing_failed", + "translation": "Failed to sign generatedd trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.base64_decode_failed", + "translation": "Failed to decode base64 for trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.missing_data", + "translation": "Trigger ID missing required data for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.expired", + "translation": "Trigger ID for interactive dialog is expired. Trigger IDs live for a maximum of {{.Seconds}} seconds." + }, + { + "id": "interactive_message.decode_trigger_id.signature_decode_failed", + "translation": "Failed to decode base64 signature of trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.verify_signature_failed", + "translation": "Signature verification failed of trigger ID for interactive dialog." + }, { "id": "api.admin.add_certificate.no_file.app_error", "translation": "No file under 'certificate' in request." diff --git a/model/client4.go b/model/client4.go index 92a0f0565d..67a24ccc24 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5,6 +5,7 @@ package model import ( "bytes" + "encoding/json" "fmt" "io" "io/ioutil" @@ -2224,7 +2225,7 @@ func (c *Client4) SearchPostsWithParams(teamId string, params *SearchParameter) } } -// SearchPosts returns any posts with matching terms string, including . +// SearchPosts returns any posts with matching terms string, including. func (c *Client4) SearchPostsWithMatches(teamId string, terms string, isOrSearch bool) (*PostSearchResults, *Response) { requestBody := map[string]interface{}{"terms": terms, "is_or_search": isOrSearch} if r, err := c.DoApiPost(c.GetTeamRoute(teamId)+"/posts/search", StringInterfaceToJson(requestBody)); err != nil { @@ -2245,6 +2246,34 @@ func (c *Client4) DoPostAction(postId, actionId string) (bool, *Response) { } } +// OpenInteractiveDialog sends a WebSocket event to a user's clients to +// open interactive dialogs, based on the provided trigger ID and other +// provided data. Used with interactive message buttons, menus and +// slash commands. +func (c *Client4) OpenInteractiveDialog(request OpenDialogRequest) (bool, *Response) { + b, _ := json.Marshal(request) + if r, err := c.DoApiPost("/actions/dialogs/open", string(b)); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + +// SubmitInteractiveDialog will submit the provided dialog data to the integration +// configured by the URL. Used with the interactive dialogs integration feature. +func (c *Client4) SubmitInteractiveDialog(request SubmitDialogRequest) (*SubmitDialogResponse, *Response) { + b, _ := json.Marshal(request) + if r, err := c.DoApiPost("/actions/dialogs/submit", string(b)); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + var resp SubmitDialogResponse + json.NewDecoder(r.Body).Decode(&resp) + return &resp, BuildResponse(r) + } +} + // File Section // UploadFile will upload a file to a channel using a multipart request, to be later attached to a post. diff --git a/model/command_args.go b/model/command_args.go index 4a635a1a1e..a3d4efa7bd 100644 --- a/model/command_args.go +++ b/model/command_args.go @@ -16,6 +16,7 @@ type CommandArgs struct { TeamId string `json:"team_id"` RootId string `json:"root_id"` ParentId string `json:"parent_id"` + TriggerId string `json:"trigger_id,omitempty"` Command string `json:"command"` SiteURL string `json:"-"` T goi18n.TranslateFunc `json:"-"` diff --git a/model/command_response.go b/model/command_response.go index 3a4ffebbcb..2f6cd0d3f3 100644 --- a/model/command_response.go +++ b/model/command_response.go @@ -25,6 +25,7 @@ type CommandResponse struct { Type string `json:"type"` Props StringInterface `json:"props"` GotoLocation string `json:"goto_location"` + TriggerId string `json:"trigger_id"` Attachments []*SlackAttachment `json:"attachments"` ExtraResponses []*CommandResponse `json:"extra_responses"` } diff --git a/model/integration_action.go b/model/integration_action.go new file mode 100644 index 0000000000..14c711b94c --- /dev/null +++ b/model/integration_action.go @@ -0,0 +1,266 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net/http" + "strconv" + "strings" +) + +const ( + POST_ACTION_TYPE_BUTTON = "button" + POST_ACTION_TYPE_SELECT = "select" + INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS = 3000 +) + +type DoPostActionRequest struct { + SelectedOption string `json:"selected_option"` +} + +type PostAction struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + DataSource string `json:"data_source"` + Options []*PostActionOptions `json:"options"` + Integration *PostActionIntegration `json:"integration,omitempty"` +} + +type PostActionOptions struct { + Text string `json:"text"` + Value string `json:"value"` +} + +type PostActionIntegration struct { + URL string `json:"url,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type PostActionIntegrationRequest struct { + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + PostId string `json:"post_id"` + TriggerId string `json:"trigger_id"` + Type string `json:"type"` + DataSource string `json:"data_source"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type PostActionIntegrationResponse struct { + Update *Post `json:"update"` + EphemeralText string `json:"ephemeral_text"` +} + +type PostActionAPIResponse struct { + Status string `json:"status"` // needed to maintain backwards compatibility + TriggerId string `json:"trigger_id"` +} + +type Dialog struct { + CallbackId string `json:"callback_id"` + Title string `json:"title"` + IconURL string `json:"icon_url"` + Elements []DialogElement `json:"elements"` + SubmitLabel string `json:"submit_label"` + NotifyOnCancel bool `json:"notify_on_cancel"` + State string `json:"state"` +} + +type DialogElement struct { + DisplayName string `json:"display_name"` + Name string `json:"name"` + Type string `json:"type"` + SubType string `json:"subtype"` + Default string `json:"default"` + Placeholder string `json:"placeholder"` + HelpText string `json:"help_text"` + Optional bool `json:"optional"` + MinLength int `json:"min_length"` + MaxLength int `json:"max_length"` + DataSource string `json:"data_source"` + Options []*PostActionOptions `json:"options"` +} + +type OpenDialogRequest struct { + TriggerId string `json:"trigger_id"` + URL string `json:"url"` + Dialog Dialog `json:"dialog"` +} + +type SubmitDialogRequest struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + CallbackId string `json:"callback_id"` + State string `json:"state"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + Submission map[string]interface{} `json:"submission"` + Cancelled bool `json:"cancelled"` +} + +type SubmitDialogResponse struct { + Errors map[string]string `json:"errors,omitempty"` +} + +func (r *PostActionIntegrationRequest) ToJson() []byte { + b, _ := json.Marshal(r) + return b +} + +func GenerateTriggerId(userId string, s crypto.Signer) (string, string, *AppError) { + clientTriggerId := NewId() + triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10)}, ":") + ":" + + h := crypto.SHA256 + sum := h.New() + sum.Write([]byte(triggerData)) + signature, err := s.Sign(rand.Reader, sum.Sum(nil), h) + if err != nil { + return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, err.Error(), http.StatusInternalServerError) + } + + base64Sig := base64.StdEncoding.EncodeToString(signature) + + triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig)) + return clientTriggerId, triggerId, nil +} + +func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) { + clientTriggerId, triggerId, err := GenerateTriggerId(r.UserId, s) + if err != nil { + return "", "", err + } + + r.TriggerId = triggerId + return clientTriggerId, triggerId, nil +} + +func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey) (string, string, *AppError) { + triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId) + if err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, err.Error(), http.StatusBadRequest) + } + + split := strings.Split(string(triggerIdBytes), ":") + if len(split) != 4 { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest) + } + + clientTriggerId := split[0] + userId := split[1] + timestampStr := split[2] + timestamp, _ := strconv.ParseInt(timestampStr, 10, 64) + + now := GetMillis() + if now-timestamp > INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]interface{}{"Seconds": INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS / 1000}, "", http.StatusBadRequest) + } + + signature, err := base64.StdEncoding.DecodeString(split[3]) + if err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, err.Error(), http.StatusBadRequest) + } + + var esig struct { + R, S *big.Int + } + + if _, err := asn1.Unmarshal([]byte(signature), &esig); err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, err.Error(), http.StatusBadRequest) + } + + triggerData := strings.Join([]string{clientTriggerId, userId, timestampStr}, ":") + ":" + + h := crypto.SHA256 + sum := h.New() + sum.Write([]byte(triggerData)) + + if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest) + } + + return clientTriggerId, userId, nil +} + +func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey) (string, string, *AppError) { + return DecodeAndVerifyTriggerId(r.TriggerId, s) +} + +func PostActionIntegrationRequestFromJson(data io.Reader) *PostActionIntegrationRequest { + var o *PostActionIntegrationRequest + err := json.NewDecoder(data).Decode(&o) + if err != nil { + return nil + } + return o +} + +func (r *PostActionIntegrationResponse) ToJson() []byte { + b, _ := json.Marshal(r) + return b +} + +func PostActionIntegrationResponseFromJson(data io.Reader) *PostActionIntegrationResponse { + var o *PostActionIntegrationResponse + err := json.NewDecoder(data).Decode(&o) + if err != nil { + return nil + } + return o +} + +func (o *Post) StripActionIntegrations() { + attachments := o.Attachments() + if o.Props["attachments"] != nil { + o.Props["attachments"] = attachments + } + for _, attachment := range attachments { + for _, action := range attachment.Actions { + action.Integration = nil + } + } +} + +func (o *Post) GetAction(id string) *PostAction { + for _, attachment := range o.Attachments() { + for _, action := range attachment.Actions { + if action.Id == id { + return action + } + } + } + return nil +} + +func (o *Post) GenerateActionIds() { + if o.Props["attachments"] != nil { + o.Props["attachments"] = o.Attachments() + } + if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { + for _, attachment := range attachments { + for _, action := range attachment.Actions { + if action.Id == "" { + action.Id = NewId() + } + } + } + } +} + +func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { + var o *DoPostActionRequest + json.NewDecoder(data).Decode(&o) + return o +} diff --git a/model/integration_action_test.go b/model/integration_action_test.go new file mode 100644 index 0000000000..d20b3fa0c2 --- /dev/null +++ b/model/integration_action_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTriggerIdDecodeAndVerification(t *testing.T) { + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.Nil(t, err) + + t.Run("should succeed decoding and validation", func(t *testing.T) { + userId := NewId() + clientTriggerId, triggerId, err := GenerateTriggerId(userId, key) + decodedClientTriggerId, decodedUserId, err := DecodeAndVerifyTriggerId(triggerId, key) + assert.Nil(t, err) + assert.Equal(t, clientTriggerId, decodedClientTriggerId) + assert.Equal(t, userId, decodedUserId) + }) + + t.Run("should succeed decoding and validation through request structs", func(t *testing.T) { + actionReq := &PostActionIntegrationRequest{ + UserId: NewId(), + } + clientTriggerId, triggerId, err := actionReq.GenerateTriggerId(key) + dialogReq := &OpenDialogRequest{TriggerId: triggerId} + decodedClientTriggerId, decodedUserId, err := dialogReq.DecodeAndVerifyTriggerId(key) + assert.Nil(t, err) + assert.Equal(t, clientTriggerId, decodedClientTriggerId) + assert.Equal(t, actionReq.UserId, decodedUserId) + }) + + t.Run("should fail on base64 decode", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId("junk!", key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed", err.Id) + }) + + t.Run("should fail on trigger parsing", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("junk!")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.missing_data", err.Id) + }) + + t.Run("should fail on expired timestamp", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:1234567890:junksignature")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.expired", err.Id) + }) + + t.Run("should fail on base64 decoding signature", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk!")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed_signature", err.Id) + }) + + t.Run("should fail on bad signature", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.signature_decode_failed", err.Id) + }) + + t.Run("should fail on bad key", func(t *testing.T) { + _, triggerId, err := GenerateTriggerId(NewId(), key) + newKey, keyErr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.Nil(t, keyErr) + _, _, err = DecodeAndVerifyTriggerId(triggerId, newKey) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.verify_signature_failed", err.Id) + }) +} + +func TestPostActionIntegrationRequestToJson(t *testing.T) { + o := PostActionIntegrationRequest{UserId: NewId(), Context: StringInterface{"a": "abc"}} + j := o.ToJson() + ro := PostActionIntegrationRequestFromJson(bytes.NewReader(j)) + + assert.NotNil(t, ro) + assert.Equal(t, o, *ro) +} + +func TestPostActionIntegrationRequestFromJsonError(t *testing.T) { + ro := PostActionIntegrationRequestFromJson(strings.NewReader("")) + assert.Nil(t, ro) +} + +func TestPostActionIntegrationResponseToJson(t *testing.T) { + o := PostActionIntegrationResponse{Update: &Post{Id: NewId(), Message: NewId()}, EphemeralText: NewId()} + j := o.ToJson() + ro := PostActionIntegrationResponseFromJson(bytes.NewReader(j)) + + assert.NotNil(t, ro) + assert.Equal(t, o, *ro) +} + +func TestPostActionIntegrationResponseFromJsonError(t *testing.T) { + ro := PostActionIntegrationResponseFromJson(strings.NewReader("")) + assert.Nil(t, ro) +} diff --git a/model/post.go b/model/post.go index 5d2438fc4e..2bf9a2c145 100644 --- a/model/post.go +++ b/model/post.go @@ -50,8 +50,6 @@ const ( PROPS_ADD_CHANNEL_MEMBER = "add_channel_member" POST_PROPS_ADDED_USER_ID = "addedUserId" POST_PROPS_DELETE_BY = "deleteBy" - POST_ACTION_TYPE_BUTTON = "button" - POST_ACTION_TYPE_SELECT = "select" ) type Post struct { @@ -132,44 +130,6 @@ type PostForIndexing struct { ParentCreateAt *int64 `json:"parent_create_at"` } -type DoPostActionRequest struct { - SelectedOption string `json:"selected_option"` -} - -type PostAction struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DataSource string `json:"data_source"` - Options []*PostActionOptions `json:"options"` - Integration *PostActionIntegration `json:"integration,omitempty"` -} - -type PostActionOptions struct { - Text string `json:"text"` - Value string `json:"value"` -} - -type PostActionIntegration struct { - URL string `json:"url,omitempty"` - Context StringInterface `json:"context,omitempty"` -} - -type PostActionIntegrationRequest struct { - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - TeamId string `json:"team_id"` - PostId string `json:"post_id"` - Type string `json:"type"` - DataSource string `json:"data_source"` - Context StringInterface `json:"context,omitempty"` -} - -type PostActionIntegrationResponse struct { - Update *Post `json:"update"` - EphemeralText string `json:"ephemeral_text"` -} - func (o *Post) ToJson() string { copy := *o copy.StripActionIntegrations() @@ -407,34 +367,6 @@ func (o *Post) ChannelMentions() []string { return ChannelMentions(o.Message) } -func (r *PostActionIntegrationRequest) ToJson() string { - b, _ := json.Marshal(r) - return string(b) -} - -func PostActionIntegrationRequesteFromJson(data io.Reader) *PostActionIntegrationRequest { - var o *PostActionIntegrationRequest - err := json.NewDecoder(data).Decode(&o) - if err != nil { - return nil - } - return o -} - -func (r *PostActionIntegrationResponse) ToJson() string { - b, _ := json.Marshal(r) - return string(b) -} - -func PostActionIntegrationResponseFromJson(data io.Reader) *PostActionIntegrationResponse { - var o *PostActionIntegrationResponse - err := json.NewDecoder(data).Decode(&o) - if err != nil { - return nil - } - return o -} - func (o *Post) Attachments() []*SlackAttachment { if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { return attachments @@ -453,44 +385,6 @@ func (o *Post) Attachments() []*SlackAttachment { return ret } -func (o *Post) StripActionIntegrations() { - attachments := o.Attachments() - if o.Props["attachments"] != nil { - o.Props["attachments"] = attachments - } - for _, attachment := range attachments { - for _, action := range attachment.Actions { - action.Integration = nil - } - } -} - -func (o *Post) GetAction(id string) *PostAction { - for _, attachment := range o.Attachments() { - for _, action := range attachment.Actions { - if action.Id == id { - return action - } - } - } - return nil -} - -func (o *Post) GenerateActionIds() { - if o.Props["attachments"] != nil { - o.Props["attachments"] = o.Attachments() - } - if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { - for _, attachment := range attachments { - for _, action := range attachment.Actions { - if action.Id == "" { - action.Id = NewId() - } - } - } - } -} - var markdownDestinationEscaper = strings.NewReplacer( `\`, `\\`, `<`, `\<`, @@ -515,12 +409,6 @@ func (o *PostEphemeral) ToUnsanitizedJson() string { return string(b) } -func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { - var o *DoPostActionRequest - json.NewDecoder(data).Decode(&o) - return o -} - // RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced // according to the function f. For each image URL, f will be invoked, and the resulting markdown // will contain the URL returned by that invocation instead. diff --git a/model/post_test.go b/model/post_test.go index b15134c89f..5f3716fe7e 100644 --- a/model/post_test.go +++ b/model/post_test.go @@ -25,34 +25,6 @@ func TestPostFromJsonError(t *testing.T) { assert.Nil(t, ro) } -func TestPostActionIntegrationRequestToJson(t *testing.T) { - o := PostActionIntegrationRequest{UserId: NewId(), Context: StringInterface{"a": "abc"}} - j := o.ToJson() - ro := PostActionIntegrationRequesteFromJson(strings.NewReader(j)) - - assert.NotNil(t, ro) - assert.Equal(t, o, *ro) -} - -func TestPostActionIntegrationRequestFromJsonError(t *testing.T) { - ro := PostActionIntegrationRequesteFromJson(strings.NewReader("")) - assert.Nil(t, ro) -} - -func TestPostActionIntegrationResponseToJson(t *testing.T) { - o := PostActionIntegrationResponse{Update: &Post{Id: NewId(), Message: NewId()}, EphemeralText: NewId()} - j := o.ToJson() - ro := PostActionIntegrationResponseFromJson(strings.NewReader(j)) - - assert.NotNil(t, ro) - assert.Equal(t, o, *ro) -} - -func TestPostActionIntegrationResponseFromJsonError(t *testing.T) { - ro := PostActionIntegrationResponseFromJson(strings.NewReader("")) - assert.Nil(t, ro) -} - func TestPostIsValid(t *testing.T) { o := Post{} maxPostSize := 10000 diff --git a/model/websocket_message.go b/model/websocket_message.go index 683f271ec5..9cf80e7871 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -49,6 +49,7 @@ const ( WEBSOCKET_EVENT_ROLE_UPDATED = "role_updated" WEBSOCKET_EVENT_LICENSE_CHANGED = "license_changed" WEBSOCKET_EVENT_CONFIG_CHANGED = "config_changed" + WEBSOCKET_EVENT_OPEN_DIALOG = "open_dialog" ) type WebSocketMessage interface { diff --git a/plugin/api.go b/plugin/api.go index 53cfea1031..606031877c 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -4,7 +4,7 @@ package plugin import ( - "github.com/hashicorp/go-plugin" + plugin "github.com/hashicorp/go-plugin" "github.com/mattermost/mattermost-server/model" ) @@ -339,6 +339,13 @@ type API interface { // Minimum server version: 5.6 UploadFile(data []byte, channelId string, filename string) (*model.FileInfo, *model.AppError) + // OpenInteractiveDialog will open an interactive dialog on a user's client that + // generated the trigger ID. Used with interactive message buttons, menus + // and slash commands. + // + // Minimum server version: 5.6 + OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError + // Plugin Section // GetPlugins will return a list of plugin manifests for currently active plugins. diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 6b928c9019..73885017b6 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -2870,6 +2870,34 @@ func (s *apiRPCServer) UploadFile(args *Z_UploadFileArgs, returns *Z_UploadFileR return nil } +type Z_OpenInteractiveDialogArgs struct { + A model.OpenDialogRequest +} + +type Z_OpenInteractiveDialogReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + _args := &Z_OpenInteractiveDialogArgs{dialog} + _returns := &Z_OpenInteractiveDialogReturns{} + if err := g.client.Call("Plugin.OpenInteractiveDialog", _args, _returns); err != nil { + log.Printf("RPC call to OpenInteractiveDialog API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) OpenInteractiveDialog(args *Z_OpenInteractiveDialogArgs, returns *Z_OpenInteractiveDialogReturns) error { + if hook, ok := s.impl.(interface { + OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError + }); ok { + returns.A = hook.OpenInteractiveDialog(args.A) + } else { + return encodableError(fmt.Errorf("API OpenInteractiveDialog called but not implemented.")) + } + return nil +} + type Z_GetPluginsArgs struct { } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 6788adf9b6..0882c5668f 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -1741,6 +1741,22 @@ func (_m *API) LogWarn(msg string, keyValuePairs ...interface{}) { _m.Called(_ca...) } +// OpenInteractiveDialog provides a mock function with given fields: dialog +func (_m *API) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + ret := _m.Called(dialog) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(model.OpenDialogRequest) *model.AppError); ok { + r0 = rf(dialog) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // PublishWebSocketEvent provides a mock function with given fields: event, payload, broadcast func (_m *API) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *model.WebsocketBroadcast) { _m.Called(event, payload, broadcast)