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
Этот коммит содержится в:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,3 +28,6 @@ func (StaticConfigService) RemoveConfigListener(string) {
|
||||
func (StaticConfigService) AsymmetricSigningKey() *ecdsa.PrivateKey {
|
||||
return &ecdsa.PrivateKey{}
|
||||
}
|
||||
func (StaticConfigService) PostActionCookieSecret() []byte {
|
||||
return make([]byte, 32)
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user