From 3ad901b50ba898e778c6e81d787ddc7ab503726f Mon Sep 17 00:00:00 2001 From: Lev <1187448+levb@users.noreply.github.com> Date: Fri, 1 Mar 2019 10:15:31 -0800 Subject: [PATCH] MM-10516: Added support for PostActions in ephemeral posts (#10258) * Added support for PostActions in ephemeral posts The general approach is that we take all the metadata that DoPostAction needs to process client DoPostActionRequests, and store it in a serialized, encrypted Cookie field, in the PostAction struct. The client then must send it back, and it is then used to process PostActions as a fallback top the metadata in the database. This PR adds a new config setting, `ServiceSettings.ActionCookieSecret`. In a cluster environment it must be the same for all instances. - Added type PostActionCookie, and a Cookie string to PostAction. - Added App.AddActionCookiesToPost. - Use App.AddActionCookiesToPost in api4.createEphemeralPost, App.SendEphemeralPost, App.UpdateEphemeralPost. - Added App.DoPostActionWithCookie to process incoming requests with cookies. For backward compatibility, it prefers the metadata in the database; falls back to cookie. - Added plugin.API.UpdateEphemeralPost and plugin.API.DeleteEphemeralPost. - Added App.encryptActionCookie/App.decryptActionCookie. * Style * Fixed an unfortunate typo, tested with matterpoll * minor PR feedback * Fixed uninitialized Context * Fixed another test failure * Fixed permission check * Added api test for DoPostActionWithCookie * Replaced config.ActionCookieSecret with Server.PostActionCookieSecret Modeled after AsymetricSigningKey * style * Set DeleteAt in DeleteEphemeralPost * PR feedback * Removed deadwood comment * Added EXPERIMENTAL comment to the 2 APIs in question --- api4/integration_action.go | 38 ++++-- api4/integration_action_test.go | 78 +++++++++++ api4/post.go | 4 +- app/config.go | 66 ++++++++++ app/config_test.go | 6 + app/integration_action.go | 156 ++++++++++++++-------- app/plugin_api.go | 8 ++ app/post.go | 45 +++++-- app/server.go | 1 + app/server_app_adapters.go | 4 + model/client4.go | 17 +++ model/integration_action.go | 157 ++++++++++++++++++++++- model/system.go | 19 ++- plugin/api.go | 8 ++ plugin/client_rpc_generated.go | 57 ++++++++ plugin/plugintest/api.go | 29 +++++ utils/testutils/static_config_service.go | 3 + 17 files changed, 611 insertions(+), 85 deletions(-) diff --git a/api4/integration_action.go b/api4/integration_action.go index b75bb6a3ed..cbffddf1c0 100644 --- a/api4/integration_action.go +++ b/api4/integration_action.go @@ -23,26 +23,46 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(c.App.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 + var cookie *model.PostActionCookie + if actionRequest.Cookie != "" { + cookie = &model.PostActionCookie{} + cookieStr, err := model.DecryptPostActionCookie(actionRequest.Cookie, c.App.PostActionCookieSecret()) + if err != nil { + c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + return + } + err = json.Unmarshal([]byte(cookieStr), &cookie) + if err != nil { + c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + return + } + if !c.App.SessionHasPermissionToChannel(c.App.Session, cookie.ChannelId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + } else { + if !c.App.SessionHasPermissionToChannelByPost(c.App.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + } + + var appErr *model.AppError resp := &model.PostActionAPIResponse{Status: "OK"} - if resp.TriggerId, err = c.App.DoPostAction(c.Params.PostId, c.Params.ActionId, c.App.Session.UserId, actionRequest.SelectedOption); err != nil { - c.Err = err + resp.TriggerId, appErr = c.App.DoPostActionWithCookie(c.Params.PostId, c.Params.ActionId, c.App.Session.UserId, + actionRequest.SelectedOption, cookie) + if appErr != nil { + c.Err = appErr return } b, _ := json.Marshal(resp) - w.Write(b) } diff --git a/api4/integration_action_test.go b/api4/integration_action_test.go index 7646712dfc..d344fadb85 100644 --- a/api4/integration_action_test.go +++ b/api4/integration_action_test.go @@ -4,7 +4,9 @@ package api4 import ( + "bytes" "encoding/json" + "io/ioutil" "net/http" "net/http/httptest" "testing" @@ -15,6 +17,82 @@ import ( "github.com/stretchr/testify/require" ) +type testHandler struct { + t *testing.T +} + +func (th *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + bb, err := ioutil.ReadAll(r.Body) + assert.Nil(th.t, err) + assert.NotEmpty(th.t, string(bb)) + poir := model.PostActionIntegrationRequestFromJson(bytes.NewReader(bb)) + assert.NotEmpty(th.t, poir.UserId) + assert.NotEmpty(th.t, poir.ChannelId) + assert.Empty(th.t, poir.TeamId) + assert.NotEmpty(th.t, poir.PostId) + assert.NotEmpty(th.t, poir.TriggerId) + assert.Equal(th.t, "button", poir.Type) + assert.Equal(th.t, "test-value", poir.Context["test-key"]) + w.Write([]byte("{}")) + w.WriteHeader(200) +} + +func TestPostActionCookies(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" + }) + + handler := &testHandler{t} + server := httptest.NewServer(handler) + action := model.PostAction{ + Id: model.NewId(), + Name: "Test-action", + Type: model.POST_ACTION_TYPE_BUTTON, + Integration: &model.PostActionIntegration{ + URL: server.URL, + Context: map[string]interface{}{ + "test-key": "test-value", + }, + }, + } + + post := &model.Post{ + Id: model.NewId(), + Type: model.POST_EPHEMERAL, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + Props: map[string]interface{}{ + "attachments": []*model.SlackAttachment{ + { + Title: "some-title", + TitleLink: "https://some-url.com", + Text: "some-text", + ImageURL: "https://some-other-url.com", + Actions: []*model.PostAction{&action}, + }, + }, + }, + } + + post.GenerateActionIds() + assert.Equal(t, 32, len(th.App.PostActionCookieSecret())) + post = model.AddPostActionCookies(post, th.App.PostActionCookieSecret()) + + ok, resp := Client.DoPostActionWithCookie(post.Id, action.Id, "", action.Cookie) + assert.True(t, ok) + assert.NotNil(t, resp) + assert.Equal(t, 200, resp.StatusCode) + assert.Nil(t, resp.Error) + assert.NotNil(t, resp.RequestId) + assert.NotNil(t, resp.ServerVersion) +} + func TestOpenDialog(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() diff --git a/api4/post.go b/api4/post.go index b5521b4094..6f62473604 100644 --- a/api4/post.go +++ b/api4/post.go @@ -97,7 +97,9 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) { rp := c.App.SendEphemeralPost(ephRequest.UserID, c.App.PostWithProxyRemovedFromImageURLs(ephRequest.Post)) w.WriteHeader(http.StatusCreated) - w.Write([]byte(c.App.PreparePostForClient(rp, true).ToJson())) + rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret()) + rp = c.App.PreparePostForClient(rp, true) + w.Write([]byte(rp.ToJson())) } func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/app/config.go b/app/config.go index 5cc7cdb886..59c4a2453d 100644 --- a/app/config.go +++ b/app/config.go @@ -100,6 +100,64 @@ func (a *App) RemoveConfigListener(id string) { a.Srv.RemoveConfigListener(id) } +// ensurePostActionCookieSecret ensures that the key for encrypting PostActionCookie exists +// and future calls to PostAcrionCookieSecret will always return a valid key, same on all +// servers in the cluster +func (a *App) ensurePostActionCookieSecret() error { + if a.Srv.postActionCookieSecret != nil { + return nil + } + + var secret *model.SystemPostActionCookieSecret + + result := <-a.Srv.Store.System().GetByName(model.SYSTEM_POST_ACTION_COOKIE_SECRET) + if result.Err == nil { + if err := json.Unmarshal([]byte(result.Data.(*model.System).Value), &secret); err != nil { + return err + } + } + + // If we don't already have a key, try to generate one. + if secret == nil { + newSecret := &model.SystemPostActionCookieSecret{ + Secret: make([]byte, 32), + } + _, err := rand.Reader.Read(newSecret.Secret) + if err != nil { + return err + } + + system := &model.System{ + Name: model.SYSTEM_POST_ACTION_COOKIE_SECRET, + } + v, err := json.Marshal(newSecret) + if err != nil { + return err + } + system.Value = string(v) + if result = <-a.Srv.Store.System().Save(system); result.Err == nil { + // If we were able to save the key, use it, otherwise ignore the error. + secret = newSecret + } + } + + // If we weren't able to save a new key above, another server must have beat us to it. Get the + // key from the database, and if that fails, error out. + if secret == nil { + result := <-a.Srv.Store.System().GetByName(model.SYSTEM_POST_ACTION_COOKIE_SECRET) + if result.Err != nil { + return result.Err + } + + if err := json.Unmarshal([]byte(result.Data.(*model.System).Value), &secret); err != nil { + return err + } + } + + a.Srv.postActionCookieSecret = secret.Secret + return nil +} + // EnsureAsymmetricSigningKey ensures that an asymmetric signing key exists and future calls to // AsymmetricSigningKey will always return a valid signing key. func (a *App) ensureAsymmetricSigningKey() error { @@ -209,6 +267,14 @@ func (a *App) AsymmetricSigningKey() *ecdsa.PrivateKey { return a.Srv.AsymmetricSigningKey() } +func (s *Server) PostActionCookieSecret() []byte { + return s.postActionCookieSecret +} + +func (a *App) PostActionCookieSecret() []byte { + return a.Srv.PostActionCookieSecret() +} + func (a *App) regenerateClientConfig() { clientConfig := config.GenerateClientConfig(a.Config(), a.DiagnosticId(), a.License()) limitedClientConfig := config.GenerateLimitedClientConfig(a.Config(), a.DiagnosticId(), a.License()) diff --git a/app/config_test.go b/app/config_test.go index 23d116c8ea..bca7e691cf 100644 --- a/app/config_test.go +++ b/app/config_test.go @@ -57,6 +57,12 @@ func TestAsymmetricSigningKey(t *testing.T) { assert.NotEmpty(t, th.App.ClientConfig()["AsymmetricSigningPublicKey"]) } +func TestPostActionCookieSecret(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + assert.Equal(t, 32, len(th.App.PostActionCookieSecret())) +} + func TestClientConfigWithComputed(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/integration_action.go b/app/integration_action.go index 6f3697ed6c..477f164477 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -31,50 +31,107 @@ import ( ) func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (string, *model.AppError) { + return a.DoPostActionWithCookie(postId, actionId, userId, selectedOption, nil) +} + +func (a *App) DoPostActionWithCookie(postId, actionId, userId, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) { + // the prop values that we need to retain/clear in replacement message to match the original + remove := []string{"override_username", "override_icon_url"} + retain := map[string]interface{}{} + datasource := "" + + upstreamURL := "" + rootPostId := "" + upstreamRequest := &model.PostActionIntegrationRequest{ + UserId: userId, + PostId: postId, + } + + // See if the post exists in the DB, if so ignore the cookie. + // Start all queries here for parallel execution 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) + if cookie == nil { + return "", result.Err + } + if cookie.Integration == nil { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest) + } - result = <-cchan - if result.Err != nil { - return "", result.Err - } - channel := result.Data.(*model.Channel) + if postId != cookie.PostId { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest) + } - 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) + upstreamRequest.ChannelId = cookie.ChannelId + upstreamRequest.Type = cookie.Type + upstreamRequest.Context = cookie.Integration.Context + datasource = cookie.DataSource + + retain = cookie.RetainProps + remove = cookie.RemoveProps + rootPostId = cookie.RootPostId + upstreamURL = cookie.Integration.URL + } else { + // Get action metadata from the database + 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) + } + + upstreamRequest.ChannelId = post.ChannelId + upstreamRequest.TeamId = channel.TeamId + upstreamRequest.Type = action.Type + upstreamRequest.Context = action.Integration.Context + datasource = action.DataSource + + retainPropKeys := []string{"override_username", "override_icon_url"} + for _, key := range retainPropKeys { + value, ok := post.Props[key] + if ok { + retain[key] = value + } else { + remove = append(remove, key) + } + } + + if post.RootId == "" { + rootPostId = post.Id + } else { + rootPostId = post.RootId + } + + upstreamURL = action.Integration.URL } - request := &model.PostActionIntegrationRequest{ - UserId: userId, - ChannelId: post.ChannelId, - TeamId: channel.TeamId, - PostId: postId, - Type: action.Type, - Context: action.Integration.Context, + if upstreamRequest.Type == model.POST_ACTION_TYPE_SELECT { + if selectedOption != "" { + if upstreamRequest.Context == nil { + upstreamRequest.Context = map[string]interface{}{} + } + upstreamRequest.DataSource = datasource + upstreamRequest.Context["selected_option"] = selectedOption + } } - clientTriggerId, _, err := request.GenerateTriggerId(a.AsymmetricSigningKey()) - if err != nil { - return "", err + clientTriggerId, _, appErr := upstreamRequest.GenerateTriggerId(a.AsymmetricSigningKey()) + if appErr != nil { + return "", appErr } - if action.Type == model.POST_ACTION_TYPE_SELECT { - request.DataSource = action.DataSource - request.Context["selected_option"] = selectedOption + resp, appErr := a.DoActionRequest(upstreamURL, upstreamRequest.ToJson()) + if appErr != nil { + return "", appErr } - - resp, err := a.DoActionRequest(action.Integration.URL, request.ToJson()) - if err != nil { - return "", err - } - defer resp.Body.Close() var response model.PostActionIntegrationResponse @@ -82,39 +139,30 @@ func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (str 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) - } + for key, value := range retain { + response.Update.AddProp(key, value) } - if _, err := a.UpdatePost(response.Update, false); err != nil { - return "", err + for _, key := range remove { + delete(response.Update.Props, key) + } + if _, appErr = a.UpdatePost(response.Update, false); appErr != nil { + return "", appErr } } 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 := &model.Post{ + Message: model.ParseSlackLinksToMarkdown(response.EphemeralText), + ChannelId: upstreamRequest.ChannelId, + RootId: rootPostId, + UserId: userId, } - 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) - } + for key, value := range retain { + ephemeralPost.AddProp(key, value) } a.SendEphemeralPost(userId, ephemeralPost) } diff --git a/app/plugin_api.go b/app/plugin_api.go index 41209a10cb..bf3a1c4f44 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -415,6 +415,14 @@ func (api *PluginAPI) SendEphemeralPost(userId string, post *model.Post) *model. return api.app.SendEphemeralPost(userId, post) } +func (api *PluginAPI) UpdateEphemeralPost(userId string, post *model.Post) *model.Post { + return api.app.UpdateEphemeralPost(userId, post) +} + +func (api *PluginAPI) DeleteEphemeralPost(userId string, post *model.Post) { + api.app.DeleteEphemeralPost(userId, post) +} + func (api *PluginAPI) DeletePost(postId string) *model.AppError { _, err := api.app.DeletePost(postId, api.id) return err diff --git a/app/post.go b/app/post.go index 85e882ee62..7d277e4456 100644 --- a/app/post.go +++ b/app/post.go @@ -404,8 +404,41 @@ func (a *App) SendEphemeralPost(userId string, post *model.Post) *model.Post { post.Props = model.StringInterface{} } + post.GenerateActionIds() message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil) - message.Add("post", a.PreparePostForClient(post, true).ToJson()) + post = a.PreparePostForClient(post, true) + post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) + message.Add("post", post.ToJson()) + a.Publish(message) + + return post +} + +func (a *App) UpdateEphemeralPost(userId string, post *model.Post) *model.Post { + post.Type = model.POST_EPHEMERAL + + post.UpdateAt = model.GetMillis() + if post.Props == nil { + post.Props = model.StringInterface{} + } + + post.GenerateActionIds() + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_EDITED, "", post.ChannelId, userId, nil) + post = a.PreparePostForClient(post, true) + post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) + message.Add("post", post.ToJson()) + a.Publish(message) + + return post +} + +func (a *App) DeleteEphemeralPost(userId string, post *model.Post) *model.Post { + post.Type = model.POST_EPHEMERAL + post.DeleteAt = model.GetMillis() + post.UpdateAt = post.DeleteAt + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_DELETED, "", post.ChannelId, userId, nil) + + message.Add("post", post.ToJson()) a.Publish(message) return post @@ -506,7 +539,9 @@ func (a *App) UpdatePost(post *model.Post, safeUpdate bool) (*model.Post, *model rpost = a.PreparePostForClient(rpost, false) - a.sendUpdatedPostEvent(rpost) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_EDITED, "", rpost.ChannelId, "", nil) + message.Add("post", rpost.ToJson()) + a.Publish(message) a.InvalidateCacheForChannelPosts(rpost.ChannelId) @@ -529,12 +564,6 @@ func (a *App) PatchPost(postId string, patch *model.PostPatch) (*model.Post, *mo return updatedPost, nil } -func (a *App) sendUpdatedPostEvent(post *model.Post) { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_EDITED, "", post.ChannelId, "", nil) - message.Add("post", post.ToJson()) - a.Publish(message) -} - func (a *App) GetPostsPage(channelId string, page int, perPage int) (*model.PostList, *model.AppError) { result := <-a.Srv.Store.Post().GetPosts(channelId, page*perPage, perPage, true) if result.Err != nil { diff --git a/app/server.go b/app/server.go index c6d5d9383d..93b2ac9a58 100644 --- a/app/server.go +++ b/app/server.go @@ -93,6 +93,7 @@ type Server struct { clusterLeaderListenerId string configStore config.Store asymmetricSigningKey *ecdsa.PrivateKey + postActionCookieSecret []byte pluginCommands []*PluginCommand pluginCommandsLock sync.RWMutex diff --git a/app/server_app_adapters.go b/app/server_app_adapters.go index 00e3e7af92..dcf9639afb 100644 --- a/app/server_app_adapters.go +++ b/app/server_app_adapters.go @@ -77,6 +77,10 @@ func (s *Server) RunOldAppInitalization() error { return errors.Wrapf(err, "unable to ensure asymmetric signing key") } + if err := s.FakeApp().ensurePostActionCookieSecret(); err != nil { + return errors.Wrapf(err, "unable to ensure PostAction cookie secret") + } + if err := s.FakeApp().ensureInstallationDate(); err != nil { return errors.Wrapf(err, "unable to ensure installation date") } diff --git a/model/client4.go b/model/client4.go index 14d5524cbf..320d471d5a 100644 --- a/model/client4.go +++ b/model/client4.go @@ -2333,6 +2333,23 @@ func (c *Client4) DoPostAction(postId, actionId string) (bool, *Response) { return CheckStatusOK(r), BuildResponse(r) } +// DoPostActionWithCookie performs a post action with extra arguments +func (c *Client4) DoPostActionWithCookie(postId, actionId, selected, cookieStr string) (bool, *Response) { + var body []byte + if selected != "" || cookieStr != "" { + body, _ = json.Marshal(DoPostActionRequest{ + SelectedOption: selected, + Cookie: cookieStr, + }) + } + r, err := c.DoApiPost(c.GetPostRoute(postId)+"/actions/"+actionId, string(body)) + if err != nil { + return false, BuildErrorResponse(r, err) + } + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) +} + // 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 diff --git a/model/integration_action.go b/model/integration_action.go index 7ea928c3f0..1c03b3296f 100644 --- a/model/integration_action.go +++ b/model/integration_action.go @@ -5,11 +5,14 @@ package model import ( "crypto" + "crypto/aes" + "crypto/cipher" "crypto/ecdsa" "crypto/rand" "encoding/asn1" "encoding/base64" "encoding/json" + "fmt" "io" "math/big" "net/http" @@ -24,16 +27,49 @@ const ( ) type DoPostActionRequest struct { - SelectedOption string `json:"selected_option"` + SelectedOption string `json:"selected_option,omitempty"` + Cookie string `json:"cookie,omitempty"` } type PostAction struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DataSource string `json:"data_source"` - Options []*PostActionOptions `json:"options"` + // A unique Action ID. If not set, generated automatically. + Id string `json:"id,omitempty"` + + // The type of the interactive element. Currently supported are + // "select" and "button". + Type string `json:"type,omitempty"` + + // The text on the button, or in the select placeholder. + Name string `json:"name,omitempty"` + + // DataSource indicates the data source for the select action. If left + // empty, the select is populated from Options. Other supported values + // are "users" and "channels". + DataSource string `json:"data_source,omitempty"` + Options []*PostActionOptions `json:"options,omitempty"` + + // Defines the interaction with the backend upon a user action. + // Integration contains Context, which is private plugin data; + // Integrations are stripped from Posts when they are sent to the + // client, or are encrypted in a Cookie. Integration *PostActionIntegration `json:"integration,omitempty"` + Cookie string `json:"cookie,omitempty" db:"-"` +} + +// PostActionCookie is set by the server, serialized and encrypted into +// PostAction.Cookie. The clients should hold on to it, and include it with +// subsequent DoPostAction requests. This allows the server to access the +// action metadata even when it's not available in the database, for ephemeral +// posts. +type PostActionCookie struct { + Type string `json:"type,omitempty"` + PostId string `json:"post_id,omitempty"` + RootPostId string `json:"root_post_id,omitempty"` + ChannelId string `json:"channel_id,omitempty"` + DataSource string `json:"data_source,omitempty"` + Integration *PostActionIntegration `json:"integration,omitempty"` + RetainProps map[string]interface{} `json:"retain_props,omitempty"` + RemoveProps []string `json:"remove_props,omitempty"` } type PostActionOptions struct { @@ -287,6 +323,115 @@ func (o *Post) GenerateActionIds() { } } +func AddPostActionCookies(o *Post, secret []byte) *Post { + p := o.Clone() + + // retainedProps carry over their value from the old post, including no value + retainPropKeys := []string{"override_username", "override_icon_url"} + retainProps := map[string]interface{}{} + removeProps := []string{} + for _, key := range retainPropKeys { + value, ok := p.Props[key] + if ok { + retainProps[key] = value + } else { + removeProps = append(removeProps, key) + } + } + + attachments := p.Attachments() + for _, attachment := range attachments { + for _, action := range attachment.Actions { + c := &PostActionCookie{ + Type: action.Type, + ChannelId: p.ChannelId, + DataSource: action.DataSource, + Integration: action.Integration, + RetainProps: retainProps, + RemoveProps: removeProps, + } + + c.PostId = p.Id + if p.RootId == "" { + c.RootPostId = p.Id + } else { + c.RootPostId = p.RootId + } + + b, _ := json.Marshal(c) + action.Cookie, _ = encryptPostActionCookie(string(b), secret) + } + } + + return p +} + +func encryptPostActionCookie(plain string, secret []byte) (string, error) { + if len(secret) == 0 { + return plain, nil + } + + block, err := aes.NewCipher(secret) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesgcm.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return "", err + } + + sealed := aesgcm.Seal(nil, nonce, []byte(plain), nil) + + combined := append(nonce, sealed...) + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(combined))) + base64.StdEncoding.Encode(encoded, combined) + + return string(encoded), nil +} + +func DecryptPostActionCookie(encoded string, secret []byte) (string, error) { + if len(secret) == 0 { + return encoded, nil + } + + block, err := aes.NewCipher(secret) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded))) + n, err := base64.StdEncoding.Decode(decoded, []byte(encoded)) + if err != nil { + return "", err + } + decoded = decoded[:n] + + nonceSize := aesgcm.NonceSize() + if len(decoded) < nonceSize { + return "", fmt.Errorf("cookie too short") + } + + nonce, decoded := decoded[:nonceSize], decoded[nonceSize:] + plain, err := aesgcm.Open(nil, nonce, decoded, nil) + if err != nil { + return "", err + } + + return string(plain), nil +} + func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { var o *DoPostActionRequest json.NewDecoder(data).Decode(&o) diff --git a/model/system.go b/model/system.go index 4228516d51..334fc43302 100644 --- a/model/system.go +++ b/model/system.go @@ -10,13 +10,14 @@ import ( ) const ( - SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" - SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" - SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" - SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" - SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" - SYSTEM_ASYMMETRIC_SIGNING_KEY = "AsymmetricSigningKey" - SYSTEM_INSTALLATION_DATE_KEY = "InstallationDate" + SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" + SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" + SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" + SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" + SYSTEM_ASYMMETRIC_SIGNING_KEY = "AsymmetricSigningKey" + SYSTEM_POST_ACTION_COOKIE_SECRET = "PostActionCookieSecret" + SYSTEM_INSTALLATION_DATE_KEY = "InstallationDate" ) type System struct { @@ -35,6 +36,10 @@ func SystemFromJson(data io.Reader) *System { return o } +type SystemPostActionCookieSecret struct { + Secret []byte `json:"key,omitempty"` +} + type SystemAsymmetricSigningKey struct { ECDSAKey *SystemECDSAKey `json:"ecdsa_key,omitempty"` } diff --git a/plugin/api.go b/plugin/api.go index a42b70ad34..99cf91cc6f 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -287,6 +287,14 @@ type API interface { // SendEphemeralPost creates an ephemeral post. SendEphemeralPost(userId string, post *model.Post) *model.Post + // UpdateEphemeralPost updates an ephemeral message previously sent to the user. + // EXPERIMENTAL: This API is experimental and can be changed without advance notice. + UpdateEphemeralPost(userId string, post *model.Post) *model.Post + + // DeleteEphemeralPost deletes an ephemeral message previously sent to the user. + // EXPERIMENTAL: This API is experimental and can be changed without advance notice. + DeleteEphemeralPost(userId string, post *model.Post) + // DeletePost deletes a post. DeletePost(postId string) *model.AppError diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 03d28ea8ce..71ae54f9c7 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -2502,6 +2502,63 @@ func (s *apiRPCServer) SendEphemeralPost(args *Z_SendEphemeralPostArgs, returns return nil } +type Z_UpdateEphemeralPostArgs struct { + A string + B *model.Post +} + +type Z_UpdateEphemeralPostReturns struct { + A *model.Post +} + +func (g *apiRPCClient) UpdateEphemeralPost(userId string, post *model.Post) *model.Post { + _args := &Z_UpdateEphemeralPostArgs{userId, post} + _returns := &Z_UpdateEphemeralPostReturns{} + if err := g.client.Call("Plugin.UpdateEphemeralPost", _args, _returns); err != nil { + log.Printf("RPC call to UpdateEphemeralPost API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) UpdateEphemeralPost(args *Z_UpdateEphemeralPostArgs, returns *Z_UpdateEphemeralPostReturns) error { + if hook, ok := s.impl.(interface { + UpdateEphemeralPost(userId string, post *model.Post) *model.Post + }); ok { + returns.A = hook.UpdateEphemeralPost(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API UpdateEphemeralPost called but not implemented.")) + } + return nil +} + +type Z_DeleteEphemeralPostArgs struct { + A string + B *model.Post +} + +type Z_DeleteEphemeralPostReturns struct { +} + +func (g *apiRPCClient) DeleteEphemeralPost(userId string, post *model.Post) { + _args := &Z_DeleteEphemeralPostArgs{userId, post} + _returns := &Z_DeleteEphemeralPostReturns{} + if err := g.client.Call("Plugin.DeleteEphemeralPost", _args, _returns); err != nil { + log.Printf("RPC call to DeleteEphemeralPost API failed: %s", err.Error()) + } + +} + +func (s *apiRPCServer) DeleteEphemeralPost(args *Z_DeleteEphemeralPostArgs, returns *Z_DeleteEphemeralPostReturns) error { + if hook, ok := s.impl.(interface { + DeleteEphemeralPost(userId string, post *model.Post) + }); ok { + hook.DeleteEphemeralPost(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API DeleteEphemeralPost called but not implemented.")) + } + return nil +} + type Z_DeletePostArgs struct { A string } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 9cfe15b026..e4f9d36186 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -2124,6 +2124,35 @@ func (_m *API) SendEphemeralPost(userId string, post *model.Post) *model.Post { return r0 } +// UpdateEphemeralPost provides a mock function with given fields: userId, post +func (_m *API) UpdateEphemeralPost(userId string, post *model.Post) *model.Post { + ret := _m.Called(userId, post) + + var r0 *model.Post + if rf, ok := ret.Get(0).(func(string, *model.Post) *model.Post); ok { + r0 = rf(userId, post) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Post) + } + } + + return r0 +} + +// DeleteEphemeralPost provides a mock function with given fields: userId, post +func (_m *API) DeleteEphemeralPost(userId string, post *model.Post) { + ret := _m.Called(userId, post) + + if rf, ok := ret.Get(0).(func(string, *model.Post) *model.Post); ok { + _ = rf(userId, post) + } else { + if ret.Get(0) != nil { + _ = ret.Get(0).(*model.Post) + } + } +} + // SendMail provides a mock function with given fields: to, subject, htmlBody func (_m *API) SendMail(to string, subject string, htmlBody string) *model.AppError { ret := _m.Called(to, subject, htmlBody) diff --git a/utils/testutils/static_config_service.go b/utils/testutils/static_config_service.go index 9ff9bbe90e..1716c2fa10 100644 --- a/utils/testutils/static_config_service.go +++ b/utils/testutils/static_config_service.go @@ -28,3 +28,6 @@ func (StaticConfigService) RemoveConfigListener(string) { func (StaticConfigService) AsymmetricSigningKey() *ecdsa.PrivateKey { return &ecdsa.PrivateKey{} } +func (StaticConfigService) PostActionCookieSecret() []byte { + return make([]byte, 32) +}