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
Этот коммит содержится в:
@@ -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
|
||||
|
||||
45
app/post.go
45
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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user