From bffac3f09fff85636cfb5da9b3cc6a354461d327 Mon Sep 17 00:00:00 2001 From: Carlos Tadeu Panato Junior Date: Fri, 16 Nov 2018 16:52:07 +0100 Subject: [PATCH 1/6] add SetTeamIcon plugin api (#9840) --- app/plugin_api.go | 14 +++++++++++++ app/plugin_api_test.go | 36 +++++++++++++++++++++++++++++++--- plugin/api.go | 5 +++++ plugin/client_rpc_generated.go | 29 +++++++++++++++++++++++++++ plugin/plugintest/api.go | 16 +++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/plugin_api.go b/app/plugin_api.go index 6d6de42f84..0f86c80b05 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -466,6 +466,20 @@ func (api *PluginAPI) GetTeamIcon(teamId string) ([]byte, *model.AppError) { return data, nil } +func (api *PluginAPI) SetTeamIcon(teamId string, data []byte) *model.AppError { + team, err := api.app.GetTeam(teamId) + if err != nil { + return err + } + + fileReader := bytes.NewReader(data) + err = api.app.SetTeamIconFromFile(team, fileReader) + if err != nil { + return err + } + return nil +} + // Plugin Section func (api *PluginAPI) GetPlugins() ([]*model.Manifest, *model.AppError) { diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index e195718bf8..b239cb07a2 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -332,12 +332,42 @@ func TestPluginAPIGetTeamIcon(t *testing.T) { require.Nil(t, err) // Get the team icon to check - imageProfile, err := api.GetTeamIcon(th.BasicTeam.Id) + teamIcon, err := api.GetTeamIcon(th.BasicTeam.Id) require.Nil(t, err) - require.NotEmpty(t, imageProfile) + require.NotEmpty(t, teamIcon) colorful := color.NRGBA{255, 0, 0, 255} - byteReader := bytes.NewReader(imageProfile) + byteReader := bytes.NewReader(teamIcon) + img2, _, err2 := image.Decode(byteReader) + require.Nil(t, err2) + require.Equal(t, img2.At(2, 3), colorful) +} + +func TestPluginAPISetTeamIcon(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + api := th.SetupPluginAPI() + + // Create an 128 x 128 image + img := image.NewRGBA(image.Rect(0, 0, 128, 128)) + // Draw a red dot at (2, 3) + img.Set(2, 3, color.RGBA{255, 0, 0, 255}) + buf := new(bytes.Buffer) + err := png.Encode(buf, img) + require.Nil(t, err) + dataBytes := buf.Bytes() + + // Set the user profile image + err = api.SetTeamIcon(th.BasicTeam.Id, dataBytes) + require.Nil(t, err) + + // Get the user profile image to check + teamIcon, err := api.GetTeamIcon(th.BasicTeam.Id) + require.Nil(t, err) + require.NotEmpty(t, teamIcon) + + colorful := color.NRGBA{255, 0, 0, 255} + byteReader := bytes.NewReader(teamIcon) img2, _, err2 := image.Decode(byteReader) require.Nil(t, err2) require.Equal(t, img2.At(2, 3), colorful) diff --git a/plugin/api.go b/plugin/api.go index 0aa8c6771a..d71a9f61fc 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -69,6 +69,11 @@ type API interface { // Minimum server version: 5.6 GetTeamIcon(teamId string) ([]byte, *model.AppError) + // SetTeamIcon sets the Team Icon. + // + // Minimum server version: 5.6 + SetTeamIcon(teamId string, data []byte) *model.AppError + // UpdateUser updates a user. UpdateUser(user *model.User) (*model.User, *model.AppError) diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 46b0c159fc..f0c28ec641 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -916,6 +916,35 @@ func (s *apiRPCServer) GetTeamIcon(args *Z_GetTeamIconArgs, returns *Z_GetTeamIc return nil } +type Z_SetTeamIconArgs struct { + A string + B []byte +} + +type Z_SetTeamIconReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) SetTeamIcon(teamId string, data []byte) *model.AppError { + _args := &Z_SetTeamIconArgs{teamId, data} + _returns := &Z_SetTeamIconReturns{} + if err := g.client.Call("Plugin.SetTeamIcon", _args, _returns); err != nil { + log.Printf("RPC call to SetTeamIcon API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) SetTeamIcon(args *Z_SetTeamIconArgs, returns *Z_SetTeamIconReturns) error { + if hook, ok := s.impl.(interface { + SetTeamIcon(teamId string, data []byte) *model.AppError + }); ok { + returns.A = hook.SetTeamIcon(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API SetTeamIcon called but not implemented.")) + } + return nil +} + type Z_UpdateUserArgs struct { A *model.User } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 2100da958c..758395d75f 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -1876,6 +1876,22 @@ func (_m *API) SetProfileImage(userId string, data []byte) *model.AppError { return r0 } +// SetTeamIcon provides a mock function with given fields: teamId, data +func (_m *API) SetTeamIcon(teamId string, data []byte) *model.AppError { + ret := _m.Called(teamId, data) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string, []byte) *model.AppError); ok { + r0 = rf(teamId, data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // UnregisterCommand provides a mock function with given fields: teamId, trigger func (_m *API) UnregisterCommand(teamId string, trigger string) error { ret := _m.Called(teamId, trigger) From 2104c6878c2600dfa349440c5f0e8537df6f94be Mon Sep 17 00:00:00 2001 From: Hanzei <16541325+hanzei@users.noreply.github.com> Date: Mon, 19 Nov 2018 14:43:49 +0100 Subject: [PATCH 2/6] [MM-12476] Consistent paging arguments limit/offset vs page/perPage for plugin API (#9838) * Change GetTeamMembers() and GetPublicChannelsForTeam() arguments to page, perPage for plugin API * Add test for GetPublicChannelsForTeam() * Add test for GetTeamMembers() * Changes as requested * Change return from GetPublicChannelsForTeam() to []*model.Channel --- app/channel_test.go | 44 ++++++++++++++++++++++++++++++++ app/plugin_api.go | 9 ++++--- app/team_test.go | 46 ++++++++++++++++++++++++++++++++++ plugin/api.go | 4 +-- plugin/client_rpc_generated.go | 14 +++++------ plugin/plugintest/api.go | 26 +++++++++---------- 6 files changed, 117 insertions(+), 26 deletions(-) diff --git a/app/channel_test.go b/app/channel_test.go index b6f4607414..9214b27b89 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -4,6 +4,7 @@ package app import ( + "fmt" "strings" "testing" @@ -747,3 +748,46 @@ func TestGetChannelMembersTimezones(t *testing.T) { } assert.Equal(t, 2, len(timezones)) } + +func TestGetPublicChannelsForTeam(t *testing.T) { + th := Setup() + team := th.CreateTeam() + defer th.TearDown() + + var expectedChannels []*model.Channel + + townSquare, err := th.App.GetChannelByName("town-square", team.Id, false) + require.Nil(t, err) + require.NotNil(t, townSquare) + expectedChannels = append(expectedChannels, townSquare) + + offTopic, err := th.App.GetChannelByName("off-topic", team.Id, false) + require.Nil(t, err) + require.NotNil(t, offTopic) + expectedChannels = append(expectedChannels, offTopic) + + for i := 0; i < 8; i++ { + channel := model.Channel{ + DisplayName: fmt.Sprintf("Public %v", i), + Name: fmt.Sprintf("public_%v", i), + Type: model.CHANNEL_OPEN, + TeamId: team.Id, + } + rchannel, err := th.App.CreateChannel(&channel, false) + require.Nil(t, err) + require.NotNil(t, rchannel) + defer th.App.PermanentDeleteChannel(rchannel) + + // Store the user ids for comparison later + expectedChannels = append(expectedChannels, rchannel) + } + + // Fetch public channels multipile times + channelList, err := th.App.GetPublicChannelsForTeam(team.Id, 0, 5) + require.Nil(t, err) + channelList2, err := th.App.GetPublicChannelsForTeam(team.Id, 5, 5) + require.Nil(t, err) + + channels := append(*channelList, *channelList2...) + assert.ElementsMatch(t, expectedChannels, channels) +} diff --git a/app/plugin_api.go b/app/plugin_api.go index 0f86c80b05..477ab8ca5d 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -132,8 +132,8 @@ func (api *PluginAPI) DeleteTeamMember(teamId, userId, requestorId string) *mode return api.app.RemoveUserFromTeam(teamId, userId, requestorId) } -func (api *PluginAPI) GetTeamMembers(teamId string, offset, limit int) ([]*model.TeamMember, *model.AppError) { - return api.app.GetTeamMembers(teamId, offset, limit) +func (api *PluginAPI) GetTeamMembers(teamId string, page, perPage int) ([]*model.TeamMember, *model.AppError) { + return api.app.GetTeamMembers(teamId, page*perPage, perPage) } func (api *PluginAPI) GetTeamMember(teamId, userId string) (*model.TeamMember, *model.AppError) { @@ -246,8 +246,9 @@ func (api *PluginAPI) DeleteChannel(channelId string) *model.AppError { return api.app.DeleteChannel(channel, "") } -func (api *PluginAPI) GetPublicChannelsForTeam(teamId string, offset, limit int) (*model.ChannelList, *model.AppError) { - return api.app.GetPublicChannelsForTeam(teamId, offset, limit) +func (api *PluginAPI) GetPublicChannelsForTeam(teamId string, page, perPage int) ([]*model.Channel, *model.AppError) { + channels, err := api.app.GetPublicChannelsForTeam(teamId, page*perPage, perPage) + return *channels, err } func (api *PluginAPI) GetChannel(channelId string) (*model.Channel, *model.AppError) { diff --git a/app/team_test.go b/app/team_test.go index 1f2dd53184..9256ffd103 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -4,10 +4,14 @@ package app import ( + "fmt" + "sort" "strings" "testing" "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCreateTeam(t *testing.T) { @@ -683,3 +687,45 @@ func TestAppUpdateTeamScheme(t *testing.T) { t.Fatal("Wrong Team SchemeId") } } + +func TestGetTeamMembers(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + var userIDs sort.StringSlice + userIDs = append(userIDs, th.BasicUser.Id) + userIDs = append(userIDs, th.BasicUser2.Id) + + for i := 0; i < 8; i++ { + user := model.User{ + Email: strings.ToLower(model.NewId()) + "success+test@example.com", + Username: fmt.Sprintf("user%v", i), + Password: "passwd1", + } + ruser, err := th.App.CreateUser(&user) + require.Nil(t, err) + require.NotNil(t, ruser) + defer th.App.PermanentDeleteUser(&user) + + _, err = th.App.AddUserToTeam(th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + // Store the user ids for comparison later + userIDs = append(userIDs, ruser.Id) + } + // Sort them because the result of GetTeamMembers() is also sorted + sort.Sort(userIDs) + + // Fetch team members multipile times + members, err := th.App.GetTeamMembers(th.BasicTeam.Id, 0, 5) + require.Nil(t, err) + // This should return 5 members + members2, err := th.App.GetTeamMembers(th.BasicTeam.Id, 5, 6) + require.Nil(t, err) + members = append(members, members2...) + + require.Equal(t, len(userIDs), len(members)) + for i, member := range members { + assert.Equal(t, userIDs[i], member.UserId) + } +} diff --git a/plugin/api.go b/plugin/api.go index d71a9f61fc..58a8812769 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -139,7 +139,7 @@ type API interface { DeleteTeamMember(teamId, userId, requestorId string) *model.AppError // GetTeamMembers returns the memberships of a specific team. - GetTeamMembers(teamId string, offset, limit int) ([]*model.TeamMember, *model.AppError) + GetTeamMembers(teamId string, page, perPage int) ([]*model.TeamMember, *model.AppError) // GetTeamMember returns a specific membership. GetTeamMember(teamId, userId string) (*model.TeamMember, *model.AppError) @@ -154,7 +154,7 @@ type API interface { DeleteChannel(channelId string) *model.AppError // GetPublicChannelsForTeam gets a list of all channels. - GetPublicChannelsForTeam(teamId string, offset, limit int) (*model.ChannelList, *model.AppError) + GetPublicChannelsForTeam(teamId string, page, perPage int) ([]*model.Channel, *model.AppError) // GetChannel gets a channel. GetChannel(channelId string) (*model.Channel, *model.AppError) diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index f0c28ec641..3c1599fc7c 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -1456,8 +1456,8 @@ type Z_GetTeamMembersReturns struct { B *model.AppError } -func (g *apiRPCClient) GetTeamMembers(teamId string, offset, limit int) ([]*model.TeamMember, *model.AppError) { - _args := &Z_GetTeamMembersArgs{teamId, offset, limit} +func (g *apiRPCClient) GetTeamMembers(teamId string, page, perPage int) ([]*model.TeamMember, *model.AppError) { + _args := &Z_GetTeamMembersArgs{teamId, page, perPage} _returns := &Z_GetTeamMembersReturns{} if err := g.client.Call("Plugin.GetTeamMembers", _args, _returns); err != nil { log.Printf("RPC call to GetTeamMembers API failed: %s", err.Error()) @@ -1467,7 +1467,7 @@ func (g *apiRPCClient) GetTeamMembers(teamId string, offset, limit int) ([]*mode func (s *apiRPCServer) GetTeamMembers(args *Z_GetTeamMembersArgs, returns *Z_GetTeamMembersReturns) error { if hook, ok := s.impl.(interface { - GetTeamMembers(teamId string, offset, limit int) ([]*model.TeamMember, *model.AppError) + GetTeamMembers(teamId string, page, perPage int) ([]*model.TeamMember, *model.AppError) }); ok { returns.A, returns.B = hook.GetTeamMembers(args.A, args.B, args.C) } else { @@ -1601,12 +1601,12 @@ type Z_GetPublicChannelsForTeamArgs struct { } type Z_GetPublicChannelsForTeamReturns struct { - A *model.ChannelList + A []*model.Channel B *model.AppError } -func (g *apiRPCClient) GetPublicChannelsForTeam(teamId string, offset, limit int) (*model.ChannelList, *model.AppError) { - _args := &Z_GetPublicChannelsForTeamArgs{teamId, offset, limit} +func (g *apiRPCClient) GetPublicChannelsForTeam(teamId string, page, perPage int) ([]*model.Channel, *model.AppError) { + _args := &Z_GetPublicChannelsForTeamArgs{teamId, page, perPage} _returns := &Z_GetPublicChannelsForTeamReturns{} if err := g.client.Call("Plugin.GetPublicChannelsForTeam", _args, _returns); err != nil { log.Printf("RPC call to GetPublicChannelsForTeam API failed: %s", err.Error()) @@ -1616,7 +1616,7 @@ func (g *apiRPCClient) GetPublicChannelsForTeam(teamId string, offset, limit int func (s *apiRPCServer) GetPublicChannelsForTeam(args *Z_GetPublicChannelsForTeamArgs, returns *Z_GetPublicChannelsForTeamReturns) error { if hook, ok := s.impl.(interface { - GetPublicChannelsForTeam(teamId string, offset, limit int) (*model.ChannelList, *model.AppError) + GetPublicChannelsForTeam(teamId string, page, perPage int) ([]*model.Channel, *model.AppError) }); ok { returns.A, returns.B = hook.GetPublicChannelsForTeam(args.A, args.B, args.C) } else { diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 758395d75f..201aa4a11d 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -1036,22 +1036,22 @@ func (_m *API) GetProfileImage(userId string) ([]byte, *model.AppError) { return r0, r1 } -// GetPublicChannelsForTeam provides a mock function with given fields: teamId, offset, limit -func (_m *API) GetPublicChannelsForTeam(teamId string, offset int, limit int) (*model.ChannelList, *model.AppError) { - ret := _m.Called(teamId, offset, limit) +// GetPublicChannelsForTeam provides a mock function with given fields: teamId, page, perPage +func (_m *API) GetPublicChannelsForTeam(teamId string, page int, perPage int) ([]*model.Channel, *model.AppError) { + ret := _m.Called(teamId, page, perPage) - var r0 *model.ChannelList - if rf, ok := ret.Get(0).(func(string, int, int) *model.ChannelList); ok { - r0 = rf(teamId, offset, limit) + var r0 []*model.Channel + if rf, ok := ret.Get(0).(func(string, int, int) []*model.Channel); ok { + r0 = rf(teamId, page, perPage) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.ChannelList) + r0 = ret.Get(0).([]*model.Channel) } } var r1 *model.AppError if rf, ok := ret.Get(1).(func(string, int, int) *model.AppError); ok { - r1 = rf(teamId, offset, limit) + r1 = rf(teamId, page, perPage) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*model.AppError) @@ -1225,13 +1225,13 @@ func (_m *API) GetTeamMember(teamId string, userId string) (*model.TeamMember, * return r0, r1 } -// GetTeamMembers provides a mock function with given fields: teamId, offset, limit -func (_m *API) GetTeamMembers(teamId string, offset int, limit int) ([]*model.TeamMember, *model.AppError) { - ret := _m.Called(teamId, offset, limit) +// GetTeamMembers provides a mock function with given fields: teamId, page, perPage +func (_m *API) GetTeamMembers(teamId string, page int, perPage int) ([]*model.TeamMember, *model.AppError) { + ret := _m.Called(teamId, page, perPage) var r0 []*model.TeamMember if rf, ok := ret.Get(0).(func(string, int, int) []*model.TeamMember); ok { - r0 = rf(teamId, offset, limit) + r0 = rf(teamId, page, perPage) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.TeamMember) @@ -1240,7 +1240,7 @@ func (_m *API) GetTeamMembers(teamId string, offset int, limit int) ([]*model.Te var r1 *model.AppError if rf, ok := ret.Get(1).(func(string, int, int) *model.AppError); ok { - r1 = rf(teamId, offset, limit) + r1 = rf(teamId, page, perPage) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*model.AppError) From 246ff8939181156050436b73da20e7cf899258aa Mon Sep 17 00:00:00 2001 From: Wasim Thabraze Date: Mon, 19 Nov 2018 20:13:31 +0530 Subject: [PATCH 3/6] MM-12463 : Added capability to bulk export custom emojis (#9790) --- app/export.go | 93 ++++++++++++++++++++++++++++++- app/export_converters.go | 10 ++++ app/export_test.go | 67 ++++++++++++++++++++++ cmd/mattermost/commands/export.go | 9 ++- 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/app/export.go b/app/export.go index 5ae02720b7..1401f391c7 100644 --- a/app/export.go +++ b/app/export.go @@ -5,14 +5,17 @@ package app import ( "encoding/json" + "errors" "io" "net/http" + "os" + "path/filepath" "strings" "github.com/mattermost/mattermost-server/model" ) -func (a *App) BulkExport(writer io.Writer) *model.AppError { +func (a *App) BulkExport(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError { if err := a.ExportVersion(writer); err != nil { return err } @@ -32,6 +35,9 @@ func (a *App) BulkExport(writer io.Writer) *model.AppError { if err := a.ExportAllPosts(writer); err != nil { return err } + if err := a.ExportCustomEmoji(writer, file, pathToEmojiDir, dirNameToExportEmoji); err != nil { + return err + } return nil } @@ -338,3 +344,88 @@ func (a *App) BuildPostReactions(postId string) (*[]ReactionImportData, *model.A return &reactionsOfPost, nil } + +func (a *App) ExportCustomEmoji(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError { + pageNumber := 0 + for { + customEmojiList, err := a.GetEmojiList(pageNumber, 100, model.EMOJI_SORT_BY_NAME) + + if err != nil { + return err + } + + if len(customEmojiList) == 0 { + break + } + + pageNumber++ + + pathToDir := a.createDirForEmoji(file, dirNameToExportEmoji) + + for _, emoji := range customEmojiList { + emojiImagePath := pathToEmojiDir + emoji.Id + "/image" + err := a.copyEmojiImages(emoji.Id, emojiImagePath, pathToDir) + if err != nil { + return model.NewAppError("BulkExport", "app.export.export_custom_emoji.copy_emoji_images.error", nil, "err="+err.Error(), http.StatusBadRequest) + } + + filePath := dirNameToExportEmoji + "/" + emoji.Id + "/image" + + emojiImportObject := ImportLineFromEmoji(emoji, filePath) + + if err := a.ExportWriteLine(writer, emojiImportObject); err != nil { + return err + } + } + } + + return nil +} + +// Creates directory named 'exported_emoji' to copy the emoji files +// Directory and the file specified by admin share the same path +func (a *App) createDirForEmoji(file string, dirName string) string { + pathToFile, _ := filepath.Abs(file) + pathSlice := strings.Split(pathToFile, "/") + if len(pathSlice) > 0 { + pathSlice = pathSlice[:len(pathSlice)-1] + } + pathToDir := strings.Join(pathSlice, "/") + "/" + dirName + + if _, err := os.Stat(pathToDir); os.IsNotExist(err) { + os.Mkdir(pathToDir, os.ModePerm) + } + return pathToDir +} + +// Copies emoji files from 'data/emoji' dir to 'exported_emoji' dir +func (a *App) copyEmojiImages(emojiId string, emojiImagePath string, pathToDir string) error { + var err error + + fromPath, err := os.Open(emojiImagePath) + if fromPath == nil || err != nil { + return errors.New("Error reading " + emojiImagePath + "file") + } + defer fromPath.Close() + + emojiDir := pathToDir + "/" + emojiId + + if _, err := os.Stat(emojiDir); os.IsNotExist(err) { + os.Mkdir(emojiDir, os.ModePerm) + } + if err != nil { + return errors.New("Error creating directory for the emoji " + err.Error()) + } + toPath, err := os.OpenFile(emojiDir+"/image", os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + return errors.New("Error creating the image file " + err.Error()) + } + defer toPath.Close() + + _, err = io.Copy(toPath, fromPath) + if err != nil { + return errors.New("Error copying emojis " + err.Error()) + } + + return nil +} diff --git a/app/export_converters.go b/app/export_converters.go index efc3a7266a..931e841365 100644 --- a/app/export_converters.go +++ b/app/export_converters.go @@ -139,3 +139,13 @@ func ImportReactionFromPost(reaction *model.Reaction) *ReactionImportData { CreateAt: &reaction.CreateAt, } } + +func ImportLineFromEmoji(emoji *model.Emoji, filePath string) *LineImportData { + return &LineImportData{ + Type: "emoji", + Emoji: &EmojiImportData{ + Name: &emoji.Name, + Image: &filePath, + }, + } +} diff --git a/app/export_test.go b/app/export_test.go index 015fde3d07..1be0d79836 100644 --- a/app/export_test.go +++ b/app/export_test.go @@ -1,6 +1,7 @@ package app import ( + "os" "testing" "github.com/stretchr/testify/assert" @@ -103,3 +104,69 @@ func TestExportUserChannels(t *testing.T) { } } } + +func TestDirCreationForEmoji(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + pathToDir := th.App.createDirForEmoji("test.json", "exported_emoji_test") + defer os.Remove(pathToDir) + if _, err := os.Stat(pathToDir); os.IsNotExist(err) { + t.Fatal("Directory exported_emoji_test should exist") + } +} + +func TestCopyEmojiImages(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + emoji := &model.Emoji{ + Id: th.BasicUser.Id, + } + + // Creating a dir named `exported_emoji_test` in the root of the repo + pathToDir := "../exported_emoji_test" + + os.Mkdir(pathToDir, 0777) + defer os.RemoveAll(pathToDir) + + filePath := "../data/emoji/" + emoji.Id + emojiImagePath := filePath + "/image" + + var _, err = os.Stat(filePath) + if os.IsNotExist(err) { + os.MkdirAll(filePath, 0777) + } + + // Creating a file with the name `image` to copy it to `exported_emoji_test` + os.OpenFile(filePath+"/image", os.O_RDONLY|os.O_CREATE, 0777) + defer os.RemoveAll(filePath) + + copyError := th.App.copyEmojiImages(emoji.Id, emojiImagePath, pathToDir) + if copyError != nil { + t.Fatal(copyError) + } + + if _, err := os.Stat(pathToDir + "/" + emoji.Id + "/image"); os.IsNotExist(err) { + t.Fatal("File should exist ", err) + } +} + +func TestExportCustomEmoji(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + filePath := "../demo.json" + + fileWriter, _ := os.Create(filePath) + defer os.Remove(filePath) + + pathToEmojiDir := "../data/emoji/" + dirNameToExportEmoji := "exported_emoji_test" + + err := th.App.ExportCustomEmoji(fileWriter, filePath, pathToEmojiDir, dirNameToExportEmoji) + defer os.RemoveAll("../" + dirNameToExportEmoji) + if err != nil { + t.Fatal(err) + } +} diff --git a/cmd/mattermost/commands/export.go b/cmd/mattermost/commands/export.go index 1d8eaeb296..0515731f15 100644 --- a/cmd/mattermost/commands/export.go +++ b/cmd/mattermost/commands/export.go @@ -180,7 +180,14 @@ func bulkExportCmdF(command *cobra.Command, args []string) error { } defer fileWriter.Close() - if err := a.BulkExport(fileWriter); err != nil { + // Path to directory of custom emoji + pathToEmojiDir := "data/emoji/" + + // Name of the directory to export custom emoji + dirNameToExportEmoji := "exported_emoji" + + // args[0] points to the filename/filepath passed with export bulk command + if err := a.BulkExport(fileWriter, args[0], pathToEmojiDir, dirNameToExportEmoji); err != nil { CommandPrettyPrintln(err.Error()) return err } From a50e8ac5b9b4b57a68927a8746cb16f11a649dd8 Mon Sep 17 00:00:00 2001 From: Michael Kochell Date: Mon, 19 Nov 2018 11:00:50 -0500 Subject: [PATCH 4/6] [MM-9938] Add support for multiple responses from a slash command (#9836) * slash command response now supports multiple posts * change wording of Posts to ExtraResponses --- app/command.go | 12 +++++++ model/command_response.go | 23 +++++++----- model/command_response_test.go | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/app/command.go b/app/command.go index e1cdc627b2..b661913e80 100644 --- a/app/command.go +++ b/app/command.go @@ -277,6 +277,18 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * } func (a *App) HandleCommandResponse(command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) { + a.HandleCommandResponsePost(command, args, response, builtIn) + + if response.ExtraResponses != nil { + for _, resp := range response.ExtraResponses { + a.HandleCommandResponsePost(command, args, resp, builtIn) + } + } + + return response, nil +} + +func (a *App) HandleCommandResponsePost(command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) { post := &model.Post{} post.ChannelId = args.ChannelId post.RootId = args.RootId diff --git a/model/command_response.go b/model/command_response.go index 1ed5286de2..3a4ffebbcb 100644 --- a/model/command_response.go +++ b/model/command_response.go @@ -18,14 +18,15 @@ const ( ) type CommandResponse struct { - ResponseType string `json:"response_type"` - Text string `json:"text"` - Username string `json:"username"` - IconURL string `json:"icon_url"` - Type string `json:"type"` - Props StringInterface `json:"props"` - GotoLocation string `json:"goto_location"` - Attachments []*SlackAttachment `json:"attachments"` + ResponseType string `json:"response_type"` + Text string `json:"text"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + Type string `json:"type"` + Props StringInterface `json:"props"` + GotoLocation string `json:"goto_location"` + Attachments []*SlackAttachment `json:"attachments"` + ExtraResponses []*CommandResponse `json:"extra_responses"` } func (o *CommandResponse) ToJson() string { @@ -63,5 +64,11 @@ func CommandResponseFromJson(data io.Reader) (*CommandResponse, error) { o.Attachments = StringifySlackFieldValue(o.Attachments) + if o.ExtraResponses != nil { + for _, resp := range o.ExtraResponses { + resp.Attachments = StringifySlackFieldValue(resp.Attachments) + } + } + return &o, nil } diff --git a/model/command_response_test.go b/model/command_response_test.go index 60179e3961..ae941048ea 100644 --- a/model/command_response_test.go +++ b/model/command_response_test.go @@ -131,6 +131,71 @@ func TestCommandResponseFromJson(t *testing.T) { }, false, }, + { + "multiple responses returned", + ` + { + "text": "message 1", + "extra_responses": [ + {"text": "message 2"} + ] + } + `, + &CommandResponse{ + Text: "message 1", + ExtraResponses: []*CommandResponse{ + &CommandResponse{ + Text: "message 2", + }, + }, + }, + false, + }, + { + "multiple responses returned, with attachments", + ` + { + "text": "message 1", + "attachments":[{"fields":[{"title":"foo","value":"bar","short":true}]}], + "extra_responses": [ + { + "text": "message 2", + "attachments":[{"fields":[{"title":"foo 2","value":"bar 2","short":false}]}] + } + ] + }`, + &CommandResponse{ + Text: "message 1", + Attachments: []*SlackAttachment{ + { + Fields: []*SlackAttachmentField{ + { + Title: "foo", + Value: "bar", + Short: true, + }, + }, + }, + }, + ExtraResponses: []*CommandResponse{ + &CommandResponse{ + Text: "message 2", + Attachments: []*SlackAttachment{ + { + Fields: []*SlackAttachmentField{ + { + Title: "foo 2", + Value: "bar 2", + Short: false, + }, + }, + }, + }, + }, + }, + }, + false, + }, } for _, testCase := range testCases { From 7a6f957638389b2371fd1f7501ba8f5e493e557f Mon Sep 17 00:00:00 2001 From: Sandeep Sukhani Date: Mon, 19 Nov 2018 22:57:15 +0530 Subject: [PATCH 5/6] [MM-11861] Design & implement a better way for plugins to update their own configuration (#9712) * [MM-11861] Design & implement a better way for plugins to update their own configuration Added GetPluginConfig and SavePluginConfig plugin APIs. Added test cases for testing new APIs. * Fixed gofmt error * Minor changes requested in PR --- app/plugin_api.go | 14 ++++++ app/plugin_api_test.go | 78 ++++++++++++++++++++++++++++++++++ plugin/api.go | 10 +++++ plugin/client_rpc_generated.go | 55 ++++++++++++++++++++++++ plugin/plugintest/api.go | 30 +++++++++++++ 5 files changed, 187 insertions(+) diff --git a/app/plugin_api.go b/app/plugin_api.go index 477ab8ca5d..63d3b21211 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -84,6 +84,20 @@ func (api *PluginAPI) SaveConfig(config *model.Config) *model.AppError { return api.app.SaveConfig(config, true) } +func (api *PluginAPI) GetPluginConfig() map[string]interface{} { + cfg := api.app.GetConfig() + if pluginConfig, isOk := cfg.PluginSettings.Plugins[api.manifest.Id]; isOk { + return pluginConfig + } + return map[string]interface{}{} +} + +func (api *PluginAPI) SavePluginConfig(pluginConfig map[string]interface{}) *model.AppError { + cfg := api.app.GetConfig() + cfg.PluginSettings.Plugins[api.manifest.Id] = pluginConfig + return api.app.SaveConfig(cfg, true) +} + func (api *PluginAPI) GetServerVersion() string { return model.CurrentVersion } diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index b239cb07a2..347fd6c327 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -63,6 +63,84 @@ func TestPluginAPIUpdateUserStatus(t *testing.T) { assert.Nil(t, status) } +func TestPluginAPISavePluginConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + manifest := &model.Manifest{ + Id: "pluginid", + SettingsSchema: &model.PluginSettingsSchema{ + Settings: []*model.PluginSetting{ + {Key: "MyStringSetting", Type: "text"}, + {Key: "MyIntSetting", Type: "text"}, + {Key: "MyBoolSetting", Type: "bool"}, + }, + }, + } + + api := NewPluginAPI(th.App, manifest) + + pluginConfigJsonString := `{"mystringsetting": "str", "MyIntSetting": 32, "myboolsetting": true}` + + var pluginConfig map[string]interface{} + if err := json.Unmarshal([]byte(pluginConfigJsonString), &pluginConfig); err != nil { + t.Fatal(err) + } + + if err := api.SavePluginConfig(pluginConfig); err != nil{ + t.Fatal(err) + } + + type Configuration struct { + MyStringSetting string + MyIntSetting int + MyBoolSetting bool + } + + savedConfiguration := new(Configuration) + if err := api.LoadPluginConfiguration(savedConfiguration); err != nil{ + t.Fatal(err) + } + + expectedConfiguration := new(Configuration) + if err := json.Unmarshal([]byte(pluginConfigJsonString), &expectedConfiguration); err != nil { + t.Fatal(err) + } + + assert.Equal(t, expectedConfiguration, savedConfiguration) +} + +func TestPluginAPIGetPluginConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + manifest := &model.Manifest{ + Id: "pluginid", + SettingsSchema: &model.PluginSettingsSchema{ + Settings: []*model.PluginSetting{ + {Key: "MyStringSetting", Type: "text"}, + {Key: "MyIntSetting", Type: "text"}, + {Key: "MyBoolSetting", Type: "bool"}, + }, + }, + } + + api := NewPluginAPI(th.App, manifest) + + pluginConfigJsonString := `{"mystringsetting": "str", "MyIntSetting": 32, "myboolsetting": true}` + var pluginConfig map[string]interface{} + + if err := json.Unmarshal([]byte(pluginConfigJsonString), &pluginConfig); err != nil { + t.Fatal(err) + } + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.Plugins["pluginid"] = pluginConfig + }) + + savedPluginConfig := api.GetPluginConfig() + assert.Equal(t, pluginConfig, savedPluginConfig) +} + func TestPluginAPILoadPluginConfiguration(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() diff --git a/plugin/api.go b/plugin/api.go index 58a8812769..53cfea1031 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -34,6 +34,16 @@ type API interface { // SaveConfig sets the given config and persists the changes SaveConfig(config *model.Config) *model.AppError + // GetPluginConfig fetches the currently persisted config of plugin + // + // Minimum server version: 5.6 + GetPluginConfig() map[string]interface{} + + // SavePluginConfig sets the given config for plugin and persists the changes + // + // Minimum server version: 5.6 + SavePluginConfig(config map[string]interface{}) *model.AppError + // GetServerVersion return the current Mattermost server version // // Minimum server version: 5.4 diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 3c1599fc7c..6b928c9019 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -656,6 +656,61 @@ func (s *apiRPCServer) SaveConfig(args *Z_SaveConfigArgs, returns *Z_SaveConfigR return nil } +type Z_GetPluginConfigArgs struct { +} + +type Z_GetPluginConfigReturns struct { + A map[string]interface{} +} + +func (g *apiRPCClient) GetPluginConfig() map[string]interface{} { + _args := &Z_GetPluginConfigArgs{} + _returns := &Z_GetPluginConfigReturns{} + if err := g.client.Call("Plugin.GetPluginConfig", _args, _returns); err != nil { + log.Printf("RPC call to GetPluginConfig API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) GetPluginConfig(args *Z_GetPluginConfigArgs, returns *Z_GetPluginConfigReturns) error { + if hook, ok := s.impl.(interface { + GetPluginConfig() map[string]interface{} + }); ok { + returns.A = hook.GetPluginConfig() + } else { + return encodableError(fmt.Errorf("API GetPluginConfig called but not implemented.")) + } + return nil +} + +type Z_SavePluginConfigArgs struct { + A map[string]interface{} +} + +type Z_SavePluginConfigReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) SavePluginConfig(config map[string]interface{}) *model.AppError { + _args := &Z_SavePluginConfigArgs{config} + _returns := &Z_SavePluginConfigReturns{} + if err := g.client.Call("Plugin.SavePluginConfig", _args, _returns); err != nil { + log.Printf("RPC call to SavePluginConfig API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) SavePluginConfig(args *Z_SavePluginConfigArgs, returns *Z_SavePluginConfigReturns) error { + if hook, ok := s.impl.(interface { + SavePluginConfig(config map[string]interface{}) *model.AppError + }); ok { + returns.A = hook.SavePluginConfig(args.A) + } else { + return encodableError(fmt.Errorf("API SavePluginConfig called but not implemented.")) + } + return nil +} + type Z_GetServerVersionArgs struct { } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 201aa4a11d..6788adf9b6 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -581,6 +581,20 @@ func (_m *API) GetConfig() *model.Config { return r0 } +// GetPluginConfig provides a mock function with given fields: +func (_m *API) GetPluginConfig() map[string]interface{} { + ret := _m.Called() + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func() map[string]interface{}); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(map[string]interface{}) + } + + return r0 +} + // GetDirectChannel provides a mock function with given fields: userId1, userId2 func (_m *API) GetDirectChannel(userId1 string, userId2 string) (*model.Channel, *model.AppError) { ret := _m.Called(userId1, userId2) @@ -1819,6 +1833,22 @@ func (_m *API) SaveConfig(config *model.Config) *model.AppError { return r0 } +// SavePluginConfig provides a mock function with given fields: pluginConfig +func (_m *API) SavePluginConfig(pluginConfig map[string]interface{}) *model.AppError { + ret := _m.Called(pluginConfig) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(map[string]interface{}) *model.AppError); ok { + r0 = rf(pluginConfig) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // SearchChannels provides a mock function with given fields: teamId, term func (_m *API) SearchChannels(teamId string, term string) (*model.ChannelList, *model.AppError) { ret := _m.Called(teamId, term) From 8cfca681b0f0cd4e9c3fd6de44b830c82a74e469 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Mon, 19 Nov 2018 15:27:17 -0500 Subject: [PATCH 6/6] MM-12843 Add interactive dialogs (#9816) * Add interactive dialogs * Fix unit test * Updates per feedback * Fix typo * Updates per feedback, add icon_url and error returns * Updates per feedback * Update per feedback --- api4/api.go | 1 + api4/command_test.go | 5 + api4/integration_action.go | 105 ++++++++++ api4/integration_action_test.go | 148 ++++++++++++++ api4/post.go | 25 --- app/command.go | 13 ++ app/integration_action.go | 200 +++++++++++++++++++ app/integration_action_test.go | 320 +++++++++++++++++++++++++++++++ app/plugin_api.go | 4 + app/post.go | 108 ----------- app/post_test.go | 241 ----------------------- i18n/en.json | 28 +++ model/client4.go | 31 ++- model/command_args.go | 1 + model/command_response.go | 1 + model/integration_action.go | 266 +++++++++++++++++++++++++ model/integration_action_test.go | 111 +++++++++++ model/post.go | 112 ----------- model/post_test.go | 28 --- model/websocket_message.go | 1 + plugin/api.go | 9 +- plugin/client_rpc_generated.go | 28 +++ plugin/plugintest/api.go | 16 ++ 23 files changed, 1286 insertions(+), 516 deletions(-) create mode 100644 api4/integration_action.go create mode 100644 api4/integration_action_test.go create mode 100644 app/integration_action.go create mode 100644 app/integration_action_test.go create mode 100644 model/integration_action.go create mode 100644 model/integration_action_test.go diff --git a/api4/api.go b/api4/api.go index f824c5cc0a..5ea06a155f 100644 --- a/api4/api.go +++ b/api4/api.go @@ -231,6 +231,7 @@ func Init(a *app.App, root *mux.Router) *API { api.InitScheme() api.InitImage() api.InitTermsOfService() + api.InitAction() root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/api4/command_test.go b/api4/command_test.go index 5c12f29005..cffedd1d81 100644 --- a/api4/command_test.go +++ b/api4/command_test.go @@ -9,6 +9,7 @@ import ( "net/url" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/model" @@ -508,7 +509,9 @@ func TestExecuteGetCommand(t *testing.T) { commandResponse, resp := Client.ExecuteCommand(channel.Id, "/getcommand") CheckNoError(t, resp) + assert.True(t, len(commandResponse.TriggerId) == 26) + expectedCommandResponse.TriggerId = commandResponse.TriggerId expectedCommandResponse.Props["from_webhook"] = "true" require.Equal(t, expectedCommandResponse, commandResponse) } @@ -566,7 +569,9 @@ func TestExecutePostCommand(t *testing.T) { commandResponse, resp := Client.ExecuteCommand(channel.Id, "/postcommand") CheckNoError(t, resp) + assert.True(t, len(commandResponse.TriggerId) == 26) + expectedCommandResponse.TriggerId = commandResponse.TriggerId expectedCommandResponse.Props["from_webhook"] = "true" require.Equal(t, expectedCommandResponse, commandResponse) diff --git a/api4/integration_action.go b/api4/integration_action.go new file mode 100644 index 0000000000..c2eb00dbac --- /dev/null +++ b/api4/integration_action.go @@ -0,0 +1,105 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/model" +) + +func (api *API) InitAction() { + api.BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9]+}", api.ApiSessionRequired(doPostAction)).Methods("POST") + + api.BaseRoutes.ApiRoot.Handle("/actions/dialogs/open", api.ApiHandler(openDialog)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/actions/dialogs/submit", api.ApiSessionRequired(submitDialog)).Methods("POST") +} + +func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePostId().RequireActionId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToChannelByPost(c.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + + actionRequest := model.DoPostActionRequestFromJson(r.Body) + if actionRequest == nil { + actionRequest = &model.DoPostActionRequest{} + } + + var err *model.AppError + resp := &model.PostActionAPIResponse{Status: "OK"} + + if resp.TriggerId, err = c.App.DoPostAction(c.Params.PostId, c.Params.ActionId, c.Session.UserId, actionRequest.SelectedOption); err != nil { + c.Err = err + return + } + + b, _ := json.Marshal(resp) + + w.Write(b) +} + +func openDialog(c *Context, w http.ResponseWriter, r *http.Request) { + var dialog model.OpenDialogRequest + err := json.NewDecoder(r.Body).Decode(&dialog) + if err != nil { + c.SetInvalidParam("dialog") + return + } + + if dialog.URL == "" { + c.SetInvalidParam("url") + return + } + + if err := c.App.OpenInteractiveDialog(dialog); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + +func submitDialog(c *Context, w http.ResponseWriter, r *http.Request) { + var submit model.SubmitDialogRequest + + jsonErr := json.NewDecoder(r.Body).Decode(&submit) + if jsonErr != nil { + c.SetInvalidParam("dialog") + return + } + + if submit.URL == "" { + c.SetInvalidParam("url") + return + } + + submit.UserId = c.Session.UserId + + if !c.App.SessionHasPermissionToChannel(c.Session, submit.ChannelId, model.PERMISSION_READ_CHANNEL) { + c.SetPermissionError(model.PERMISSION_READ_CHANNEL) + return + } + + if !c.App.SessionHasPermissionToTeam(c.Session, submit.TeamId, model.PERMISSION_VIEW_TEAM) { + c.SetPermissionError(model.PERMISSION_VIEW_TEAM) + return + } + + resp, err := c.App.SubmitInteractiveDialog(submit) + if err != nil { + c.Err = err + return + } + + b, _ := json.Marshal(resp) + + w.Write(b) +} diff --git a/api4/integration_action_test.go b/api4/integration_action_test.go new file mode 100644 index 0000000000..902e1715a9 --- /dev/null +++ b/api4/integration_action_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/require" +) + +func TestOpenDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + WebSocketClient, err := th.CreateWebSocketClient() + require.Nil(t, err) + + WebSocketClient.Listen() + + _, triggerId, err := model.GenerateTriggerId(th.BasicUser.Id, th.App.AsymmetricSigningKey()) + require.Nil(t, err) + + request := model.OpenDialogRequest{ + TriggerId: triggerId, + URL: "http://localhost:8065", + Dialog: model.Dialog{ + CallbackId: "callbackid", + Title: "Some Title", + Elements: []model.DialogElement{ + model.DialogElement{ + DisplayName: "Element Name", + Name: "element_name", + Type: "text", + Placeholder: "Enter a value", + }, + }, + SubmitLabel: "Submit", + NotifyOnCancel: false, + State: "somestate", + }, + } + + pass, resp := Client.OpenInteractiveDialog(request) + CheckNoError(t, resp) + assert.True(t, pass) + + timeout := time.After(300 * time.Millisecond) + waiting := true + for waiting { + select { + case event := <-WebSocketClient.EventChannel: + if event.Event == model.WEBSOCKET_EVENT_OPEN_DIALOG { + waiting = false + } + + case <-timeout: + waiting = false + t.Fatal("should have received open_dialog event") + } + } + + // Should fail on bad trigger ID + request.TriggerId = "junk" + pass, resp = Client.OpenInteractiveDialog(request) + CheckBadRequestStatus(t, resp) + assert.False(t, pass) + + // URL is required + request.TriggerId = triggerId + request.URL = "" + pass, resp = Client.OpenInteractiveDialog(request) + CheckBadRequestStatus(t, resp) + assert.False(t, pass) +} + +func TestSubmitDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + submit := model.SubmitDialogRequest{ + CallbackId: "callbackid", + State: "somestate", + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + Submission: map[string]interface{}{"somename": "somevalue"}, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request model.SubmitDialogRequest + err := json.NewDecoder(r.Body).Decode(&request) + require.Nil(t, err) + assert.NotNil(t, request) + + assert.Equal(t, request.URL, "") + assert.Equal(t, request.UserId, submit.UserId) + assert.Equal(t, request.ChannelId, submit.ChannelId) + assert.Equal(t, request.TeamId, submit.TeamId) + assert.Equal(t, request.CallbackId, submit.CallbackId) + assert.Equal(t, request.State, submit.State) + val, ok := request.Submission["somename"].(string) + require.True(t, ok) + assert.Equal(t, "somevalue", val) + })) + defer ts.Close() + + submit.URL = ts.URL + + submitResp, resp := Client.SubmitInteractiveDialog(submit) + CheckNoError(t, resp) + assert.NotNil(t, submitResp) + + submit.URL = "" + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckBadRequestStatus(t, resp) + assert.Nil(t, submitResp) + + submit.URL = ts.URL + submit.ChannelId = model.NewId() + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckForbiddenStatus(t, resp) + assert.Nil(t, submitResp) + + submit.URL = ts.URL + submit.ChannelId = th.BasicChannel.Id + submit.TeamId = model.NewId() + submitResp, resp = Client.SubmitInteractiveDialog(submit) + CheckForbiddenStatus(t, resp) + assert.Nil(t, submitResp) +} diff --git a/api4/post.go b/api4/post.go index 7c116b7c77..02a269b15b 100644 --- a/api4/post.go +++ b/api4/post.go @@ -25,7 +25,6 @@ func (api *API) InitPost() { api.BaseRoutes.Team.Handle("/posts/search", api.ApiSessionRequired(searchPosts)).Methods("POST") api.BaseRoutes.Post.Handle("", api.ApiSessionRequired(updatePost)).Methods("PUT") api.BaseRoutes.Post.Handle("/patch", api.ApiSessionRequired(patchPost)).Methods("PUT") - api.BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9]+}", api.ApiSessionRequired(doPostAction)).Methods("POST") api.BaseRoutes.Post.Handle("/pin", api.ApiSessionRequired(pinPost)).Methods("POST") api.BaseRoutes.Post.Handle("/unpin", api.ApiSessionRequired(unpinPost)).Methods("POST") } @@ -529,27 +528,3 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set(model.HEADER_ETAG_SERVER, model.GetEtagForFileInfos(infos)) w.Write([]byte(model.FileInfosToJson(infos))) } - -func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { - c.RequirePostId().RequireActionId() - if c.Err != nil { - return - } - - if !c.App.SessionHasPermissionToChannelByPost(c.Session, c.Params.PostId, model.PERMISSION_READ_CHANNEL) { - c.SetPermissionError(model.PERMISSION_READ_CHANNEL) - return - } - - actionRequest := model.DoPostActionRequestFromJson(r.Body) - if actionRequest == nil { - actionRequest = &model.DoPostActionRequest{} - } - - if err := c.App.DoPostAction(c.Params.PostId, c.Params.ActionId, c.Session.UserId, actionRequest.SelectedOption); err != nil { - c.Err = err - return - } - - ReturnStatusOK(w) -} diff --git a/app/command.go b/app/command.go index b661913e80..490bc25ecb 100644 --- a/app/command.go +++ b/app/command.go @@ -162,6 +162,13 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * message := strings.Join(parts[1:], " ") provider := GetCommandProvider(trigger) + clientTriggerId, triggerId, appErr := model.GenerateTriggerId(args.UserId, a.AsymmetricSigningKey()) + if appErr != nil { + mlog.Error(appErr.Error()) + } + + args.TriggerId = triggerId + if provider != nil { if cmd := provider.GetCommand(a, args.T); cmd != nil { response := provider.DoCommand(a, args, message) @@ -174,6 +181,7 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * return nil, appErr } if cmd != nil { + response.TriggerId = clientTriggerId return a.HandleCommandResponse(cmd, args, response, true) } @@ -228,6 +236,8 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * p.Set("command", "/"+trigger) p.Set("text", message) + p.Set("trigger_id", triggerId) + hook, appErr := a.CreateCommandWebhook(cmd.Id, args) if appErr != nil { return nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]interface{}{"Trigger": trigger}, appErr.Error(), http.StatusInternalServerError) @@ -269,6 +279,9 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * if response == nil { return nil, model.NewAppError("command", "api.command.execute_command.failed_empty.app_error", map[string]interface{}{"Trigger": trigger}, "", http.StatusInternalServerError) } + + response.TriggerId = clientTriggerId + return a.HandleCommandResponse(cmd, args, response, false) } } diff --git a/app/integration_action.go b/app/integration_action.go new file mode 100644 index 0000000000..a8ea700279 --- /dev/null +++ b/app/integration_action.go @@ -0,0 +1,200 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +// Integration Action Flow +// +// 1. An integration creates an interactive message button or menu. +// 2. A user clicks on a button or selects an option from the menu. +// 3. The client sends a request to server to complete the post action, calling DoPostAction below. +// 4. DoPostAction will send an HTTP POST request to the integration containing contextual data, including +// an encoded and signed trigger ID. Slash commands also include trigger IDs in their payloads. +// 5. The integration performs any actions it needs to and optionally makes a request back to the MM server +// using the trigger ID to open an interactive dialog. +// 6. If that optional request is made, OpenInteractiveDialog sends a WebSocket event to all connected clients +// for the relevant user, telling them to display the dialog. +// 7. The user fills in the dialog and submits it, where SubmitInteractiveDialog will submit it back to the +// integration for handling. + +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/services/httpservice" + "github.com/mattermost/mattermost-server/utils" +) + +func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) (string, *model.AppError) { + pchan := a.Srv.Store.Post().GetSingle(postId) + cchan := a.Srv.Store.Channel().GetForPost(postId) + + result := <-pchan + if result.Err != nil { + return "", result.Err + } + post := result.Data.(*model.Post) + + result = <-cchan + if result.Err != nil { + return "", result.Err + } + channel := result.Data.(*model.Channel) + + action := post.GetAction(actionId) + if action == nil || action.Integration == nil { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) + } + + request := &model.PostActionIntegrationRequest{ + UserId: userId, + ChannelId: post.ChannelId, + TeamId: channel.TeamId, + PostId: postId, + Type: action.Type, + Context: action.Integration.Context, + } + + clientTriggerId, _, err := request.GenerateTriggerId(a.AsymmetricSigningKey()) + if err != nil { + return "", err + } + + if action.Type == model.POST_ACTION_TYPE_SELECT { + request.DataSource = action.DataSource + request.Context["selected_option"] = selectedOption + } + + resp, err := a.DoActionRequest(action.Integration.URL, request.ToJson()) + if resp != nil { + defer consumeAndClose(resp) + } + if err != nil { + return "", err + } + + var response model.PostActionIntegrationResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return "", model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + + retainedProps := []string{"override_username", "override_icon_url"} + + if response.Update != nil { + response.Update.Id = postId + response.Update.AddProp("from_webhook", "true") + for _, prop := range retainedProps { + if value, ok := post.Props[prop]; ok { + response.Update.Props[prop] = value + } else { + delete(response.Update.Props, prop) + } + } + if _, err := a.UpdatePost(response.Update, false); err != nil { + return "", err + } + } + + if response.EphemeralText != "" { + ephemeralPost := &model.Post{} + ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText) + ephemeralPost.ChannelId = post.ChannelId + ephemeralPost.RootId = post.RootId + if ephemeralPost.RootId == "" { + ephemeralPost.RootId = post.Id + } + ephemeralPost.UserId = post.UserId + ephemeralPost.AddProp("from_webhook", "true") + for _, prop := range retainedProps { + if value, ok := post.Props[prop]; ok { + ephemeralPost.Props[prop] = value + } else { + delete(ephemeralPost.Props, prop) + } + } + a.SendEphemeralPost(userId, ephemeralPost) + } + + return clientTriggerId, nil +} + +// Perform an HTTP POST request to an integration's action endpoint. +// Caller must consume and close returned http.Response as necessary. +func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { + req, _ := http.NewRequest("POST", rawURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Allow access to plugin routes for action buttons + var httpClient *httpservice.Client + url, _ := url.Parse(rawURL) + siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) + subpath, _ := utils.GetSubpathFromConfig(a.Config()) + if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) { + httpClient = a.HTTPService.MakeClient(true) + } else { + httpClient = a.HTTPService.MakeClient(false) + } + + resp, httpErr := httpClient.Do(req) + if httpErr != nil { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+httpErr.Error(), http.StatusBadRequest) + } + + if resp.StatusCode != http.StatusOK { + return resp, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) + } + + return resp, nil +} + +func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { + clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) + if err != nil { + return err + } + + request.TriggerId = clientTriggerId + + jsonRequest, _ := json.Marshal(request) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_OPEN_DIALOG, "", "", userId, nil) + message.Add("dialog", string(jsonRequest)) + a.Publish(message) + + return nil +} + +func (a *App) SubmitInteractiveDialog(request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) { + url := request.URL + request.URL = "" + request.Type = "dialog_submission" + + b, jsonErr := json.Marshal(request) + if jsonErr != nil { + return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.json_error", nil, jsonErr.Error(), http.StatusBadRequest) + } + + resp, err := a.DoActionRequest(url, b) + if resp != nil { + defer consumeAndClose(resp) + } + + if err != nil { + return nil, err + } + + var response model.SubmitDialogResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + // Don't fail, an empty response is acceptable + return &response, nil + } + + return &response, nil +} diff --git a/app/integration_action_test.go b/app/integration_action_test.go new file mode 100644 index 0000000000..070bf3519f --- /dev/null +++ b/app/integration_action_test.go @@ -0,0 +1,320 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" +) + +func TestPostAction(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := model.PostActionIntegrationRequestFromJson(r.Body) + assert.NotNil(t, request) + + assert.Equal(t, request.UserId, th.BasicUser.Id) + assert.Equal(t, request.ChannelId, th.BasicChannel.Id) + assert.Equal(t, request.TeamId, th.BasicTeam.Id) + assert.True(t, len(request.TriggerId) > 0) + if request.Type == model.POST_ACTION_TYPE_SELECT { + assert.Equal(t, request.DataSource, "some_source") + assert.Equal(t, request.Context["selected_option"], "selected") + } else { + assert.Equal(t, request.DataSource, "") + } + assert.Equal(t, "foo", request.Context["s"]) + assert.EqualValues(t, 3, request.Context["n"]) + fmt.Fprintf(w, `{"post": {"message": "updated"}, "ephemeral_text": "foo"}`) + })) + defer ts.Close() + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL, + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, false) + require.Nil(t, err) + + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + menuPost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL, + }, + Name: "action", + Type: model.POST_ACTION_TYPE_SELECT, + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + post2, err := th.App.CreatePostAsUser(&menuPost, false) + require.Nil(t, err) + + attachments2, ok := post2.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + require.NotEmpty(t, attachments2[0].Actions) + require.NotEmpty(t, attachments2[0].Actions[0].Id) + + clientTriggerId, err := th.App.DoPostAction(post.Id, "notavalidid", th.BasicUser.Id, "") + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + assert.True(t, clientTriggerId == "") + + clientTriggerId, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + assert.True(t, len(clientTriggerId) == 26) + + clientTriggerId, err = th.App.DoPostAction(post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected") + require.Nil(t, err) + assert.True(t, len(clientTriggerId) == 26) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + }) + + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + require.True(t, strings.Contains(err.Error(), "address forbidden")) + + interactivePostPlugin := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL + "/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postplugin, err := th.App.CreatePostAsUser(&interactivePostPlugin, false) + require.Nil(t, err) + + attachmentsPlugin, ok := postplugin.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.SiteURL = "http://127.1.1.1" + }) + + interactivePostSiteURL := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: "http://127.1.1.1/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postSiteURL, err := th.App.CreatePostAsUser(&interactivePostSiteURL, false) + require.Nil(t, err) + + attachmentsSiteURL, ok := postSiteURL.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + require.False(t, strings.Contains(err.Error(), "address forbidden")) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.SiteURL = ts.URL + "/subpath" + }) + + interactivePostSubpath := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "s": "foo", + "n": 3, + }, + URL: ts.URL + "/subpath/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + DataSource: "some_source", + }, + }, + }, + }, + }, + } + + postSubpath, err := th.App.CreatePostAsUser(&interactivePostSubpath, false) + require.Nil(t, err) + + attachmentsSubpath, ok := postSubpath.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + + _, err = th.App.DoPostAction(postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) +} + +func TestSubmitInteractiveDialog(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" + }) + + submit := model.SubmitDialogRequest{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + TeamId: th.BasicTeam.Id, + CallbackId: "someid", + State: "somestate", + Submission: map[string]interface{}{ + "name1": "value1", + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request model.SubmitDialogRequest + err := json.NewDecoder(r.Body).Decode(&request) + require.Nil(t, err) + assert.NotNil(t, request) + + assert.Equal(t, request.URL, "") + assert.Equal(t, request.UserId, submit.UserId) + assert.Equal(t, request.ChannelId, submit.ChannelId) + assert.Equal(t, request.TeamId, submit.TeamId) + assert.Equal(t, request.CallbackId, submit.CallbackId) + assert.Equal(t, request.State, submit.State) + val, ok := request.Submission["name1"].(string) + require.True(t, ok) + assert.Equal(t, "value1", val) + + resp := model.SubmitDialogResponse{ + Errors: map[string]string{"name1": "some error"}, + } + + b, _ := json.Marshal(resp) + + w.Write(b) + })) + defer ts.Close() + + submit.URL = ts.URL + + resp, err := th.App.SubmitInteractiveDialog(submit) + assert.Nil(t, err) + require.NotNil(t, resp) + assert.Equal(t, "some error", resp.Errors["name1"]) + + submit.URL = "" + resp, err = th.App.SubmitInteractiveDialog(submit) + assert.NotNil(t, err) + assert.Nil(t, resp) +} diff --git a/app/plugin_api.go b/app/plugin_api.go index 63d3b21211..78be7dbf46 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -495,6 +495,10 @@ func (api *PluginAPI) SetTeamIcon(teamId string, data []byte) *model.AppError { return nil } +func (api *PluginAPI) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + return api.app.OpenInteractiveDialog(dialog) +} + // Plugin Section func (api *PluginAPI) GetPlugins() ([]*model.Manifest, *model.AppError) { diff --git a/app/post.go b/app/post.go index 8f391325ce..c4909a9d42 100644 --- a/app/post.go +++ b/app/post.go @@ -7,12 +7,10 @@ import ( "crypto/hmac" "crypto/sha1" "encoding/hex" - "encoding/json" "fmt" "io" "net/http" "net/url" - "path" "strings" "github.com/dyatlov/go-opengraph/opengraph" @@ -21,7 +19,6 @@ import ( "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" - "github.com/mattermost/mattermost-server/services/httpservice" "github.com/mattermost/mattermost-server/store" "github.com/mattermost/mattermost-server/utils" ) @@ -862,111 +859,6 @@ func makeOpenGraphURLsAbsolute(og *opengraph.OpenGraph, requestURL string) { } } -func (a *App) DoPostAction(postId, actionId, userId, selectedOption string) *model.AppError { - pchan := a.Srv.Store.Post().GetSingle(postId) - cchan := a.Srv.Store.Channel().GetForPost(postId) - - result := <-pchan - if result.Err != nil { - return result.Err - } - post := result.Data.(*model.Post) - - result = <-cchan - if result.Err != nil { - return result.Err - } - channel := result.Data.(*model.Channel) - - action := post.GetAction(actionId) - if action == nil || action.Integration == nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound) - } - - request := &model.PostActionIntegrationRequest{ - UserId: userId, - ChannelId: post.ChannelId, - TeamId: channel.TeamId, - PostId: postId, - Type: action.Type, - Context: action.Integration.Context, - } - - if action.Type == model.POST_ACTION_TYPE_SELECT { - request.DataSource = action.DataSource - request.Context["selected_option"] = selectedOption - } - - req, _ := http.NewRequest("POST", action.Integration.URL, strings.NewReader(request.ToJson())) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Allow access to plugin routes for action buttons - var httpClient *httpservice.Client - url, _ := url.Parse(action.Integration.URL) - siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) - subpath, _ := utils.GetSubpathFromConfig(a.Config()) - if (url.Hostname() == "localhost" || url.Hostname() == "127.0.0.1" || url.Hostname() == siteURL.Hostname()) && strings.HasPrefix(url.Path, path.Join(subpath, "plugins")) { - httpClient = a.HTTPService.MakeClient(true) - } else { - httpClient = a.HTTPService.MakeClient(false) - } - - resp, err := httpClient.Do(req) - if err != nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) - } - defer consumeAndClose(resp) - - if resp.StatusCode != http.StatusOK { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, fmt.Sprintf("status=%v", resp.StatusCode), http.StatusBadRequest) - } - - var response model.PostActionIntegrationResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) - } - - retainedProps := []string{"override_username", "override_icon_url"} - - if response.Update != nil { - response.Update.Id = postId - response.Update.AddProp("from_webhook", "true") - for _, prop := range retainedProps { - if value, ok := post.Props[prop]; ok { - response.Update.Props[prop] = value - } else { - delete(response.Update.Props, prop) - } - } - if _, err := a.UpdatePost(response.Update, false); err != nil { - return err - } - } - - if response.EphemeralText != "" { - ephemeralPost := &model.Post{} - ephemeralPost.Message = model.ParseSlackLinksToMarkdown(response.EphemeralText) - ephemeralPost.ChannelId = post.ChannelId - ephemeralPost.RootId = post.RootId - if ephemeralPost.RootId == "" { - ephemeralPost.RootId = post.Id - } - ephemeralPost.UserId = post.UserId - ephemeralPost.AddProp("from_webhook", "true") - for _, prop := range retainedProps { - if value, ok := post.Props[prop]; ok { - ephemeralPost.Props[prop] = value - } else { - delete(ephemeralPost.Props, prop) - } - } - a.SendEphemeralPost(userId, ephemeralPost) - } - - return nil -} - func (a *App) PostListWithProxyAddedToImageURLs(list *model.PostList) *model.PostList { if f := a.ImageProxyAdder(); f != nil { return list.WithRewrittenImageURLs(f) diff --git a/app/post_test.go b/app/post_test.go index ed5cb76f2c..8a0e81c1bb 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -6,7 +6,6 @@ package app import ( "fmt" "net/http" - "net/http/httptest" "strings" "sync/atomic" "testing" @@ -120,246 +119,6 @@ func TestPostReplyToPostWhereRootPosterLeftChannel(t *testing.T) { } } -func TestPostAction(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost 127.0.0.1" - }) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - request := model.PostActionIntegrationRequesteFromJson(r.Body) - assert.NotNil(t, request) - - assert.Equal(t, request.UserId, th.BasicUser.Id) - assert.Equal(t, request.ChannelId, th.BasicChannel.Id) - assert.Equal(t, request.TeamId, th.BasicTeam.Id) - if request.Type == model.POST_ACTION_TYPE_SELECT { - assert.Equal(t, request.DataSource, "some_source") - assert.Equal(t, request.Context["selected_option"], "selected") - } else { - assert.Equal(t, request.DataSource, "") - } - assert.Equal(t, "foo", request.Context["s"]) - assert.EqualValues(t, 3, request.Context["n"]) - fmt.Fprintf(w, `{"post": {"message": "updated"}, "ephemeral_text": "foo"}`) - })) - defer ts.Close() - - interactivePost := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL, - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - post, err := th.App.CreatePostAsUser(&interactivePost, false) - require.Nil(t, err) - - attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - require.NotEmpty(t, attachments[0].Actions) - require.NotEmpty(t, attachments[0].Actions[0].Id) - - menuPost := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL, - }, - Name: "action", - Type: model.POST_ACTION_TYPE_SELECT, - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - post2, err := th.App.CreatePostAsUser(&menuPost, false) - require.Nil(t, err) - - attachments2, ok := post2.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - require.NotEmpty(t, attachments2[0].Actions) - require.NotEmpty(t, attachments2[0].Actions[0].Id) - - err = th.App.DoPostAction(post.Id, "notavalidid", th.BasicUser.Id, "") - require.NotNil(t, err) - assert.Equal(t, http.StatusNotFound, err.StatusCode) - - err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) - - err = th.App.DoPostAction(post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected") - require.Nil(t, err) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" - }) - - err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.NotNil(t, err) - require.True(t, strings.Contains(err.Error(), "address forbidden")) - - interactivePostPlugin := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL + "/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postplugin, err := th.App.CreatePostAsUser(&interactivePostPlugin, false) - require.Nil(t, err) - - attachmentsPlugin, ok := postplugin.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.SiteURL = "http://127.1.1.1" - }) - - interactivePostSiteURL := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: "http://127.1.1.1/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postSiteURL, err := th.App.CreatePostAsUser(&interactivePostSiteURL, false) - require.Nil(t, err) - - attachmentsSiteURL, ok := postSiteURL.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "") - require.NotNil(t, err) - require.False(t, strings.Contains(err.Error(), "address forbidden")) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.SiteURL = ts.URL + "/subpath" - }) - - interactivePostSubpath := model.Post{ - Message: "Interactive post", - ChannelId: th.BasicChannel.Id, - PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), - UserId: th.BasicUser.Id, - Props: model.StringInterface{ - "attachments": []*model.SlackAttachment{ - { - Text: "hello", - Actions: []*model.PostAction{ - { - Integration: &model.PostActionIntegration{ - Context: model.StringInterface{ - "s": "foo", - "n": 3, - }, - URL: ts.URL + "/subpath/plugins/myplugin/myaction", - }, - Name: "action", - Type: "some_type", - DataSource: "some_source", - }, - }, - }, - }, - }, - } - - postSubpath, err := th.App.CreatePostAsUser(&interactivePostSubpath, false) - require.Nil(t, err) - - attachmentsSubpath, ok := postSubpath.Props["attachments"].([]*model.SlackAttachment) - require.True(t, ok) - - err = th.App.DoPostAction(postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) -} - func TestPostChannelMentions(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() diff --git a/i18n/en.json b/i18n/en.json index 3f8dca0f01..3cfcf35326 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -7,6 +7,34 @@ "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." }, + { + "id": "app.submit_interactive_dialog.json_error", + "translation": "Encountered an error encoding JSON for the interactive dialog." + }, + { + "id": "interactive_message.generate_trigger_id.signing_failed", + "translation": "Failed to sign generatedd trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.base64_decode_failed", + "translation": "Failed to decode base64 for trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.missing_data", + "translation": "Trigger ID missing required data for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.expired", + "translation": "Trigger ID for interactive dialog is expired. Trigger IDs live for a maximum of {{.Seconds}} seconds." + }, + { + "id": "interactive_message.decode_trigger_id.signature_decode_failed", + "translation": "Failed to decode base64 signature of trigger ID for interactive dialog." + }, + { + "id": "interactive_message.decode_trigger_id.verify_signature_failed", + "translation": "Signature verification failed of trigger ID for interactive dialog." + }, { "id": "api.admin.add_certificate.no_file.app_error", "translation": "No file under 'certificate' in request." diff --git a/model/client4.go b/model/client4.go index 92a0f0565d..67a24ccc24 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5,6 +5,7 @@ package model import ( "bytes" + "encoding/json" "fmt" "io" "io/ioutil" @@ -2224,7 +2225,7 @@ func (c *Client4) SearchPostsWithParams(teamId string, params *SearchParameter) } } -// SearchPosts returns any posts with matching terms string, including . +// SearchPosts returns any posts with matching terms string, including. func (c *Client4) SearchPostsWithMatches(teamId string, terms string, isOrSearch bool) (*PostSearchResults, *Response) { requestBody := map[string]interface{}{"terms": terms, "is_or_search": isOrSearch} if r, err := c.DoApiPost(c.GetTeamRoute(teamId)+"/posts/search", StringInterfaceToJson(requestBody)); err != nil { @@ -2245,6 +2246,34 @@ func (c *Client4) DoPostAction(postId, actionId string) (bool, *Response) { } } +// OpenInteractiveDialog sends a WebSocket event to a user's clients to +// open interactive dialogs, based on the provided trigger ID and other +// provided data. Used with interactive message buttons, menus and +// slash commands. +func (c *Client4) OpenInteractiveDialog(request OpenDialogRequest) (bool, *Response) { + b, _ := json.Marshal(request) + if r, err := c.DoApiPost("/actions/dialogs/open", string(b)); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + +// SubmitInteractiveDialog will submit the provided dialog data to the integration +// configured by the URL. Used with the interactive dialogs integration feature. +func (c *Client4) SubmitInteractiveDialog(request SubmitDialogRequest) (*SubmitDialogResponse, *Response) { + b, _ := json.Marshal(request) + if r, err := c.DoApiPost("/actions/dialogs/submit", string(b)); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + var resp SubmitDialogResponse + json.NewDecoder(r.Body).Decode(&resp) + return &resp, BuildResponse(r) + } +} + // File Section // UploadFile will upload a file to a channel using a multipart request, to be later attached to a post. diff --git a/model/command_args.go b/model/command_args.go index 4a635a1a1e..a3d4efa7bd 100644 --- a/model/command_args.go +++ b/model/command_args.go @@ -16,6 +16,7 @@ type CommandArgs struct { TeamId string `json:"team_id"` RootId string `json:"root_id"` ParentId string `json:"parent_id"` + TriggerId string `json:"trigger_id,omitempty"` Command string `json:"command"` SiteURL string `json:"-"` T goi18n.TranslateFunc `json:"-"` diff --git a/model/command_response.go b/model/command_response.go index 3a4ffebbcb..2f6cd0d3f3 100644 --- a/model/command_response.go +++ b/model/command_response.go @@ -25,6 +25,7 @@ type CommandResponse struct { Type string `json:"type"` Props StringInterface `json:"props"` GotoLocation string `json:"goto_location"` + TriggerId string `json:"trigger_id"` Attachments []*SlackAttachment `json:"attachments"` ExtraResponses []*CommandResponse `json:"extra_responses"` } diff --git a/model/integration_action.go b/model/integration_action.go new file mode 100644 index 0000000000..14c711b94c --- /dev/null +++ b/model/integration_action.go @@ -0,0 +1,266 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net/http" + "strconv" + "strings" +) + +const ( + POST_ACTION_TYPE_BUTTON = "button" + POST_ACTION_TYPE_SELECT = "select" + INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS = 3000 +) + +type DoPostActionRequest struct { + SelectedOption string `json:"selected_option"` +} + +type PostAction struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + DataSource string `json:"data_source"` + Options []*PostActionOptions `json:"options"` + Integration *PostActionIntegration `json:"integration,omitempty"` +} + +type PostActionOptions struct { + Text string `json:"text"` + Value string `json:"value"` +} + +type PostActionIntegration struct { + URL string `json:"url,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type PostActionIntegrationRequest struct { + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + PostId string `json:"post_id"` + TriggerId string `json:"trigger_id"` + Type string `json:"type"` + DataSource string `json:"data_source"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type PostActionIntegrationResponse struct { + Update *Post `json:"update"` + EphemeralText string `json:"ephemeral_text"` +} + +type PostActionAPIResponse struct { + Status string `json:"status"` // needed to maintain backwards compatibility + TriggerId string `json:"trigger_id"` +} + +type Dialog struct { + CallbackId string `json:"callback_id"` + Title string `json:"title"` + IconURL string `json:"icon_url"` + Elements []DialogElement `json:"elements"` + SubmitLabel string `json:"submit_label"` + NotifyOnCancel bool `json:"notify_on_cancel"` + State string `json:"state"` +} + +type DialogElement struct { + DisplayName string `json:"display_name"` + Name string `json:"name"` + Type string `json:"type"` + SubType string `json:"subtype"` + Default string `json:"default"` + Placeholder string `json:"placeholder"` + HelpText string `json:"help_text"` + Optional bool `json:"optional"` + MinLength int `json:"min_length"` + MaxLength int `json:"max_length"` + DataSource string `json:"data_source"` + Options []*PostActionOptions `json:"options"` +} + +type OpenDialogRequest struct { + TriggerId string `json:"trigger_id"` + URL string `json:"url"` + Dialog Dialog `json:"dialog"` +} + +type SubmitDialogRequest struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + CallbackId string `json:"callback_id"` + State string `json:"state"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + Submission map[string]interface{} `json:"submission"` + Cancelled bool `json:"cancelled"` +} + +type SubmitDialogResponse struct { + Errors map[string]string `json:"errors,omitempty"` +} + +func (r *PostActionIntegrationRequest) ToJson() []byte { + b, _ := json.Marshal(r) + return b +} + +func GenerateTriggerId(userId string, s crypto.Signer) (string, string, *AppError) { + clientTriggerId := NewId() + triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10)}, ":") + ":" + + h := crypto.SHA256 + sum := h.New() + sum.Write([]byte(triggerData)) + signature, err := s.Sign(rand.Reader, sum.Sum(nil), h) + if err != nil { + return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, err.Error(), http.StatusInternalServerError) + } + + base64Sig := base64.StdEncoding.EncodeToString(signature) + + triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig)) + return clientTriggerId, triggerId, nil +} + +func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) { + clientTriggerId, triggerId, err := GenerateTriggerId(r.UserId, s) + if err != nil { + return "", "", err + } + + r.TriggerId = triggerId + return clientTriggerId, triggerId, nil +} + +func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey) (string, string, *AppError) { + triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId) + if err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, err.Error(), http.StatusBadRequest) + } + + split := strings.Split(string(triggerIdBytes), ":") + if len(split) != 4 { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest) + } + + clientTriggerId := split[0] + userId := split[1] + timestampStr := split[2] + timestamp, _ := strconv.ParseInt(timestampStr, 10, 64) + + now := GetMillis() + if now-timestamp > INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]interface{}{"Seconds": INTERACTIVE_DIALOG_TRIGGER_TIMEOUT_MILLISECONDS / 1000}, "", http.StatusBadRequest) + } + + signature, err := base64.StdEncoding.DecodeString(split[3]) + if err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, err.Error(), http.StatusBadRequest) + } + + var esig struct { + R, S *big.Int + } + + if _, err := asn1.Unmarshal([]byte(signature), &esig); err != nil { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, err.Error(), http.StatusBadRequest) + } + + triggerData := strings.Join([]string{clientTriggerId, userId, timestampStr}, ":") + ":" + + h := crypto.SHA256 + sum := h.New() + sum.Write([]byte(triggerData)) + + if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) { + return "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest) + } + + return clientTriggerId, userId, nil +} + +func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey) (string, string, *AppError) { + return DecodeAndVerifyTriggerId(r.TriggerId, s) +} + +func PostActionIntegrationRequestFromJson(data io.Reader) *PostActionIntegrationRequest { + var o *PostActionIntegrationRequest + err := json.NewDecoder(data).Decode(&o) + if err != nil { + return nil + } + return o +} + +func (r *PostActionIntegrationResponse) ToJson() []byte { + b, _ := json.Marshal(r) + return b +} + +func PostActionIntegrationResponseFromJson(data io.Reader) *PostActionIntegrationResponse { + var o *PostActionIntegrationResponse + err := json.NewDecoder(data).Decode(&o) + if err != nil { + return nil + } + return o +} + +func (o *Post) StripActionIntegrations() { + attachments := o.Attachments() + if o.Props["attachments"] != nil { + o.Props["attachments"] = attachments + } + for _, attachment := range attachments { + for _, action := range attachment.Actions { + action.Integration = nil + } + } +} + +func (o *Post) GetAction(id string) *PostAction { + for _, attachment := range o.Attachments() { + for _, action := range attachment.Actions { + if action.Id == id { + return action + } + } + } + return nil +} + +func (o *Post) GenerateActionIds() { + if o.Props["attachments"] != nil { + o.Props["attachments"] = o.Attachments() + } + if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { + for _, attachment := range attachments { + for _, action := range attachment.Actions { + if action.Id == "" { + action.Id = NewId() + } + } + } + } +} + +func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { + var o *DoPostActionRequest + json.NewDecoder(data).Decode(&o) + return o +} diff --git a/model/integration_action_test.go b/model/integration_action_test.go new file mode 100644 index 0000000000..d20b3fa0c2 --- /dev/null +++ b/model/integration_action_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTriggerIdDecodeAndVerification(t *testing.T) { + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.Nil(t, err) + + t.Run("should succeed decoding and validation", func(t *testing.T) { + userId := NewId() + clientTriggerId, triggerId, err := GenerateTriggerId(userId, key) + decodedClientTriggerId, decodedUserId, err := DecodeAndVerifyTriggerId(triggerId, key) + assert.Nil(t, err) + assert.Equal(t, clientTriggerId, decodedClientTriggerId) + assert.Equal(t, userId, decodedUserId) + }) + + t.Run("should succeed decoding and validation through request structs", func(t *testing.T) { + actionReq := &PostActionIntegrationRequest{ + UserId: NewId(), + } + clientTriggerId, triggerId, err := actionReq.GenerateTriggerId(key) + dialogReq := &OpenDialogRequest{TriggerId: triggerId} + decodedClientTriggerId, decodedUserId, err := dialogReq.DecodeAndVerifyTriggerId(key) + assert.Nil(t, err) + assert.Equal(t, clientTriggerId, decodedClientTriggerId) + assert.Equal(t, actionReq.UserId, decodedUserId) + }) + + t.Run("should fail on base64 decode", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId("junk!", key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed", err.Id) + }) + + t.Run("should fail on trigger parsing", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("junk!")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.missing_data", err.Id) + }) + + t.Run("should fail on expired timestamp", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:1234567890:junksignature")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.expired", err.Id) + }) + + t.Run("should fail on base64 decoding signature", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk!")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.base64_decode_failed_signature", err.Id) + }) + + t.Run("should fail on bad signature", func(t *testing.T) { + _, _, err := DecodeAndVerifyTriggerId(base64.StdEncoding.EncodeToString([]byte("some-trigger-id:some-user-id:12345678900000:junk")), key) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.signature_decode_failed", err.Id) + }) + + t.Run("should fail on bad key", func(t *testing.T) { + _, triggerId, err := GenerateTriggerId(NewId(), key) + newKey, keyErr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.Nil(t, keyErr) + _, _, err = DecodeAndVerifyTriggerId(triggerId, newKey) + require.NotNil(t, err) + assert.Equal(t, "interactive_message.decode_trigger_id.verify_signature_failed", err.Id) + }) +} + +func TestPostActionIntegrationRequestToJson(t *testing.T) { + o := PostActionIntegrationRequest{UserId: NewId(), Context: StringInterface{"a": "abc"}} + j := o.ToJson() + ro := PostActionIntegrationRequestFromJson(bytes.NewReader(j)) + + assert.NotNil(t, ro) + assert.Equal(t, o, *ro) +} + +func TestPostActionIntegrationRequestFromJsonError(t *testing.T) { + ro := PostActionIntegrationRequestFromJson(strings.NewReader("")) + assert.Nil(t, ro) +} + +func TestPostActionIntegrationResponseToJson(t *testing.T) { + o := PostActionIntegrationResponse{Update: &Post{Id: NewId(), Message: NewId()}, EphemeralText: NewId()} + j := o.ToJson() + ro := PostActionIntegrationResponseFromJson(bytes.NewReader(j)) + + assert.NotNil(t, ro) + assert.Equal(t, o, *ro) +} + +func TestPostActionIntegrationResponseFromJsonError(t *testing.T) { + ro := PostActionIntegrationResponseFromJson(strings.NewReader("")) + assert.Nil(t, ro) +} diff --git a/model/post.go b/model/post.go index 5d2438fc4e..2bf9a2c145 100644 --- a/model/post.go +++ b/model/post.go @@ -50,8 +50,6 @@ const ( PROPS_ADD_CHANNEL_MEMBER = "add_channel_member" POST_PROPS_ADDED_USER_ID = "addedUserId" POST_PROPS_DELETE_BY = "deleteBy" - POST_ACTION_TYPE_BUTTON = "button" - POST_ACTION_TYPE_SELECT = "select" ) type Post struct { @@ -132,44 +130,6 @@ type PostForIndexing struct { ParentCreateAt *int64 `json:"parent_create_at"` } -type DoPostActionRequest struct { - SelectedOption string `json:"selected_option"` -} - -type PostAction struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DataSource string `json:"data_source"` - Options []*PostActionOptions `json:"options"` - Integration *PostActionIntegration `json:"integration,omitempty"` -} - -type PostActionOptions struct { - Text string `json:"text"` - Value string `json:"value"` -} - -type PostActionIntegration struct { - URL string `json:"url,omitempty"` - Context StringInterface `json:"context,omitempty"` -} - -type PostActionIntegrationRequest struct { - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - TeamId string `json:"team_id"` - PostId string `json:"post_id"` - Type string `json:"type"` - DataSource string `json:"data_source"` - Context StringInterface `json:"context,omitempty"` -} - -type PostActionIntegrationResponse struct { - Update *Post `json:"update"` - EphemeralText string `json:"ephemeral_text"` -} - func (o *Post) ToJson() string { copy := *o copy.StripActionIntegrations() @@ -407,34 +367,6 @@ func (o *Post) ChannelMentions() []string { return ChannelMentions(o.Message) } -func (r *PostActionIntegrationRequest) ToJson() string { - b, _ := json.Marshal(r) - return string(b) -} - -func PostActionIntegrationRequesteFromJson(data io.Reader) *PostActionIntegrationRequest { - var o *PostActionIntegrationRequest - err := json.NewDecoder(data).Decode(&o) - if err != nil { - return nil - } - return o -} - -func (r *PostActionIntegrationResponse) ToJson() string { - b, _ := json.Marshal(r) - return string(b) -} - -func PostActionIntegrationResponseFromJson(data io.Reader) *PostActionIntegrationResponse { - var o *PostActionIntegrationResponse - err := json.NewDecoder(data).Decode(&o) - if err != nil { - return nil - } - return o -} - func (o *Post) Attachments() []*SlackAttachment { if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { return attachments @@ -453,44 +385,6 @@ func (o *Post) Attachments() []*SlackAttachment { return ret } -func (o *Post) StripActionIntegrations() { - attachments := o.Attachments() - if o.Props["attachments"] != nil { - o.Props["attachments"] = attachments - } - for _, attachment := range attachments { - for _, action := range attachment.Actions { - action.Integration = nil - } - } -} - -func (o *Post) GetAction(id string) *PostAction { - for _, attachment := range o.Attachments() { - for _, action := range attachment.Actions { - if action.Id == id { - return action - } - } - } - return nil -} - -func (o *Post) GenerateActionIds() { - if o.Props["attachments"] != nil { - o.Props["attachments"] = o.Attachments() - } - if attachments, ok := o.Props["attachments"].([]*SlackAttachment); ok { - for _, attachment := range attachments { - for _, action := range attachment.Actions { - if action.Id == "" { - action.Id = NewId() - } - } - } - } -} - var markdownDestinationEscaper = strings.NewReplacer( `\`, `\\`, `<`, `\<`, @@ -515,12 +409,6 @@ func (o *PostEphemeral) ToUnsanitizedJson() string { return string(b) } -func DoPostActionRequestFromJson(data io.Reader) *DoPostActionRequest { - var o *DoPostActionRequest - json.NewDecoder(data).Decode(&o) - return o -} - // RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced // according to the function f. For each image URL, f will be invoked, and the resulting markdown // will contain the URL returned by that invocation instead. diff --git a/model/post_test.go b/model/post_test.go index b15134c89f..5f3716fe7e 100644 --- a/model/post_test.go +++ b/model/post_test.go @@ -25,34 +25,6 @@ func TestPostFromJsonError(t *testing.T) { assert.Nil(t, ro) } -func TestPostActionIntegrationRequestToJson(t *testing.T) { - o := PostActionIntegrationRequest{UserId: NewId(), Context: StringInterface{"a": "abc"}} - j := o.ToJson() - ro := PostActionIntegrationRequesteFromJson(strings.NewReader(j)) - - assert.NotNil(t, ro) - assert.Equal(t, o, *ro) -} - -func TestPostActionIntegrationRequestFromJsonError(t *testing.T) { - ro := PostActionIntegrationRequesteFromJson(strings.NewReader("")) - assert.Nil(t, ro) -} - -func TestPostActionIntegrationResponseToJson(t *testing.T) { - o := PostActionIntegrationResponse{Update: &Post{Id: NewId(), Message: NewId()}, EphemeralText: NewId()} - j := o.ToJson() - ro := PostActionIntegrationResponseFromJson(strings.NewReader(j)) - - assert.NotNil(t, ro) - assert.Equal(t, o, *ro) -} - -func TestPostActionIntegrationResponseFromJsonError(t *testing.T) { - ro := PostActionIntegrationResponseFromJson(strings.NewReader("")) - assert.Nil(t, ro) -} - func TestPostIsValid(t *testing.T) { o := Post{} maxPostSize := 10000 diff --git a/model/websocket_message.go b/model/websocket_message.go index 683f271ec5..9cf80e7871 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -49,6 +49,7 @@ const ( WEBSOCKET_EVENT_ROLE_UPDATED = "role_updated" WEBSOCKET_EVENT_LICENSE_CHANGED = "license_changed" WEBSOCKET_EVENT_CONFIG_CHANGED = "config_changed" + WEBSOCKET_EVENT_OPEN_DIALOG = "open_dialog" ) type WebSocketMessage interface { diff --git a/plugin/api.go b/plugin/api.go index 53cfea1031..606031877c 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -4,7 +4,7 @@ package plugin import ( - "github.com/hashicorp/go-plugin" + plugin "github.com/hashicorp/go-plugin" "github.com/mattermost/mattermost-server/model" ) @@ -339,6 +339,13 @@ type API interface { // Minimum server version: 5.6 UploadFile(data []byte, channelId string, filename string) (*model.FileInfo, *model.AppError) + // OpenInteractiveDialog will open an interactive dialog on a user's client that + // generated the trigger ID. Used with interactive message buttons, menus + // and slash commands. + // + // Minimum server version: 5.6 + OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError + // Plugin Section // GetPlugins will return a list of plugin manifests for currently active plugins. diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 6b928c9019..73885017b6 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -2870,6 +2870,34 @@ func (s *apiRPCServer) UploadFile(args *Z_UploadFileArgs, returns *Z_UploadFileR return nil } +type Z_OpenInteractiveDialogArgs struct { + A model.OpenDialogRequest +} + +type Z_OpenInteractiveDialogReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + _args := &Z_OpenInteractiveDialogArgs{dialog} + _returns := &Z_OpenInteractiveDialogReturns{} + if err := g.client.Call("Plugin.OpenInteractiveDialog", _args, _returns); err != nil { + log.Printf("RPC call to OpenInteractiveDialog API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) OpenInteractiveDialog(args *Z_OpenInteractiveDialogArgs, returns *Z_OpenInteractiveDialogReturns) error { + if hook, ok := s.impl.(interface { + OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError + }); ok { + returns.A = hook.OpenInteractiveDialog(args.A) + } else { + return encodableError(fmt.Errorf("API OpenInteractiveDialog called but not implemented.")) + } + return nil +} + type Z_GetPluginsArgs struct { } diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 6788adf9b6..0882c5668f 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -1741,6 +1741,22 @@ func (_m *API) LogWarn(msg string, keyValuePairs ...interface{}) { _m.Called(_ca...) } +// OpenInteractiveDialog provides a mock function with given fields: dialog +func (_m *API) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { + ret := _m.Called(dialog) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(model.OpenDialogRequest) *model.AppError); ok { + r0 = rf(dialog) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // PublishWebSocketEvent provides a mock function with given fields: event, payload, broadcast func (_m *API) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *model.WebsocketBroadcast) { _m.Called(event, payload, broadcast)