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
Этот коммит содержится в:
Lev
2019-03-01 10:15:31 -08:00
коммит произвёл GitHub
родитель dcf611b735
Коммит 3ad901b50b
17 изменённых файлов: 611 добавлений и 85 удалений

Просмотреть файл

@@ -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())

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -93,6 +93,7 @@ type Server struct {
clusterLeaderListenerId string
configStore config.Store
asymmetricSigningKey *ecdsa.PrivateKey
postActionCookieSecret []byte
pluginCommands []*PluginCommand
pluginCommandsLock sync.RWMutex

Просмотреть файл

@@ -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")
}