diff --git a/api4/bot.go b/api4/bot.go index e0d532b2be..6809894a96 100644 --- a/api4/bot.go +++ b/api4/bot.go @@ -4,7 +4,11 @@ package api4 import ( + "fmt" + "io" + "io/ioutil" "net/http" + "strconv" "github.com/mattermost/mattermost-server/model" ) @@ -17,6 +21,10 @@ func (api *API) InitBot() { api.BaseRoutes.Bot.Handle("/disable", api.ApiSessionRequired(disableBot)).Methods("POST") api.BaseRoutes.Bot.Handle("/enable", api.ApiSessionRequired(enableBot)).Methods("POST") api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.ApiSessionRequired(assignBot)).Methods("POST") + + api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequiredTrustRequester(getBotIconImage)).Methods("GET") + api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequired(setBotIconImage)).Methods("POST") + api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequired(deleteBotIconImage)).Methods("DELETE") } func createBot(c *Context, w http.ResponseWriter, r *http.Request) { @@ -217,3 +225,127 @@ func assignBot(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(bot.ToJson()) } + +func getBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireBotUserId() + if c.Err != nil { + return + } + botUserId := c.Params.BotUserId + + canSee, err := c.App.UserCanSeeOtherUser(c.App.Session.UserId, botUserId) + if err != nil { + c.Err = err + return + } + + if !canSee { + c.SetPermissionError(model.PERMISSION_VIEW_MEMBERS) + return + } + + user, err := c.App.GetUser(botUserId) + if err != nil { + c.Err = err + return + } + if !user.IsBot { + c.Err = model.MakeBotNotFoundError(botUserId) + return + } + + etag := strconv.FormatInt(user.LastPictureUpdate, 10) + if c.HandleEtag(etag, "Get Icon Image", w, r) { + return + } + + img, readFailed, err := c.App.GetBotIconImage(user.Id) + if err != nil { + c.Err = err + return + } + + if readFailed { + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%v, public", 5*60)) // 5 mins + } else { + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%v, public", 24*60*60)) // 24 hrs + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + } + + w.Header().Set("Content-Type", "image/svg+xml") + w.Write(img) +} + +func setBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) { + defer io.Copy(ioutil.Discard, r.Body) + + c.RequireBotUserId() + if c.Err != nil { + return + } + botUserId := c.Params.BotUserId + + if err := c.App.SessionHasPermissionToManageBot(c.App.Session, botUserId); err != nil { + c.Err = err + return + } + + if _, err := c.App.GetBot(botUserId, true); err != nil { + c.Err = model.MakeBotNotFoundError(botUserId) + return + } + + if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize { + c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge) + return + } + + if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil { + c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.parse.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + m := r.MultipartForm + imageArray, ok := m.File["image"] + if !ok { + c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.no_file.app_error", nil, "", http.StatusBadRequest) + return + } + + if len(imageArray) <= 0 { + c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.array.app_error", nil, "", http.StatusBadRequest) + return + } + + imageData := imageArray[0] + if err := c.App.SetBotIconImage(botUserId, imageData); err != nil { + c.Err = err + return + } + + c.LogAudit("") + ReturnStatusOK(w) +} + +func deleteBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) { + defer io.Copy(ioutil.Discard, r.Body) + + c.RequireBotUserId() + if c.Err != nil { + return + } + botUserId := c.Params.BotUserId + + if err := c.App.SessionHasPermissionToManageBot(c.App.Session, botUserId); err != nil { + c.Err = err + return + } + + if err := c.App.DeleteBotIconImage(botUserId); err != nil { + c.Err = err + return + } + + c.LogAudit("") + ReturnStatusOK(w) +} diff --git a/api4/bot_test.go b/api4/bot_test.go index 9e14f60d68..48d3532cd7 100644 --- a/api4/bot_test.go +++ b/api4/bot_test.go @@ -4,11 +4,17 @@ package api4 import ( + "fmt" "io/ioutil" + "net/http" + "os" + "path/filepath" "strings" "testing" "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/utils/fileutils" + "github.com/mattermost/mattermost-server/utils/testutils" "github.com/stretchr/testify/require" ) @@ -1090,6 +1096,196 @@ func TestAssignBot(t *testing.T) { }) } +func TestSetBotIconImage(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + user := th.BasicUser + + defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions()) + + th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableBotAccountCreation = true + }) + + bot := &model.Bot{ + Username: GenerateTestUsername(), + Description: "bot", + } + bot, resp := th.Client.CreateBot(bot) + CheckCreatedStatus(t, resp) + defer th.App.PermanentDeleteBot(bot.UserId) + + badData, err := testutils.ReadTestFile("test.png") + require.Nil(t, err) + + goodData, err := testutils.ReadTestFile("test.svg") + require.Nil(t, err) + + // SetBotIconImage only allowed for bots + _, resp = th.SystemAdminClient.SetBotIconImage(user.Id, goodData) + CheckNotFoundStatus(t, resp) + + // png/jpg is not allowed + ok, resp := th.Client.SetBotIconImage(bot.UserId, badData) + require.False(t, ok, "Should return false, set icon image only allows svg") + CheckBadRequestStatus(t, resp) + + ok, resp = th.Client.SetBotIconImage(model.NewId(), badData) + require.False(t, ok, "Should return false, set icon image not allowed") + CheckNotFoundStatus(t, resp) + + _, resp = th.Client.SetBotIconImage(bot.UserId, goodData) + CheckNoError(t, resp) + + // status code returns either forbidden or unauthorized + // note: forbidden is set as default at Client4.SetBotIconImage when request is terminated early by server + th.Client.Logout() + _, resp = th.Client.SetBotIconImage(bot.UserId, badData) + if resp.StatusCode == http.StatusForbidden { + CheckForbiddenStatus(t, resp) + } else if resp.StatusCode == http.StatusUnauthorized { + CheckUnauthorizedStatus(t, resp) + } else { + require.Fail(t, "Should have failed either forbidden or unauthorized") + } + + _, resp = th.SystemAdminClient.SetBotIconImage(bot.UserId, goodData) + CheckNoError(t, resp) + + info := &model.FileInfo{Path: "/bots/" + bot.UserId + "/icon.svg"} + err = th.cleanupTestFile(info) + require.Nil(t, err) +} + +func TestGetBotIconImage(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions()) + + th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableBotAccountCreation = true + }) + + bot := &model.Bot{ + Username: GenerateTestUsername(), + Description: "bot", + } + bot, resp := th.Client.CreateBot(bot) + CheckCreatedStatus(t, resp) + defer th.App.PermanentDeleteBot(bot.UserId) + + // Get icon image for user with no icon + data, resp := th.Client.GetBotIconImage(bot.UserId) + CheckNotFoundStatus(t, resp) + require.Equal(t, 0, len(data)) + + // Set an icon image + path, _ := fileutils.FindDir("tests") + svgFile, fileErr := os.Open(filepath.Join(path, "test.svg")) + require.NoError(t, fileErr) + defer svgFile.Close() + + expectedData, err := ioutil.ReadAll(svgFile) + require.NoError(t, err) + + svgFile.Seek(0, 0) + fpath := fmt.Sprintf("/bots/%v/icon.svg", bot.UserId) + _, err = th.App.WriteFile(svgFile, fpath) + require.Nil(t, err) + + data, resp = th.Client.GetBotIconImage(bot.UserId) + CheckNoError(t, resp) + require.Equal(t, expectedData, data) + + _, resp = th.Client.GetBotIconImage("junk") + CheckBadRequestStatus(t, resp) + + _, resp = th.Client.GetBotIconImage(model.NewId()) + CheckNotFoundStatus(t, resp) + + th.Client.Logout() + _, resp = th.Client.GetBotIconImage(bot.UserId) + CheckUnauthorizedStatus(t, resp) + + _, resp = th.SystemAdminClient.GetBotIconImage(bot.UserId) + CheckNoError(t, resp) + + info := &model.FileInfo{Path: "/bots/" + bot.UserId + "/icon.svg"} + err = th.cleanupTestFile(info) + require.Nil(t, err) +} + +func TestDeleteBotIconImage(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions()) + + th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableBotAccountCreation = true + }) + + bot := &model.Bot{ + Username: GenerateTestUsername(), + Description: "bot", + } + bot, resp := th.Client.CreateBot(bot) + CheckCreatedStatus(t, resp) + defer th.App.PermanentDeleteBot(bot.UserId) + + // Get icon image for user with no icon + data, resp := th.Client.GetBotIconImage(bot.UserId) + CheckNotFoundStatus(t, resp) + require.Equal(t, 0, len(data)) + + // Set an icon image + svgData, err := testutils.ReadTestFile("test.svg") + require.Nil(t, err) + + _, resp = th.Client.SetBotIconImage(bot.UserId, svgData) + CheckNoError(t, resp) + + fpath := fmt.Sprintf("/bots/%v/icon.svg", bot.UserId) + exists, err := th.App.FileExists(fpath) + require.Nil(t, err) + require.True(t, exists, "icon.svg needs to exist for the user") + + data, resp = th.Client.GetBotIconImage(bot.UserId) + CheckNoError(t, resp) + require.Equal(t, svgData, data) + + success, resp := th.Client.DeleteBotIconImage("junk") + CheckBadRequestStatus(t, resp) + require.False(t, success) + + success, resp = th.Client.DeleteBotIconImage(model.NewId()) + CheckNotFoundStatus(t, resp) + require.False(t, success) + + success, resp = th.Client.DeleteBotIconImage(bot.UserId) + CheckNoError(t, resp) + require.True(t, success) + + th.Client.Logout() + success, resp = th.Client.DeleteBotIconImage(bot.UserId) + CheckUnauthorizedStatus(t, resp) + require.False(t, success) + + exists, err = th.App.FileExists(fpath) + require.Nil(t, err) + require.False(t, exists, "icon.svg should not for the user") +} + func sToP(s string) *string { return &s } diff --git a/app/bot.go b/app/bot.go index a2d0c63d5b..7c62df18d2 100644 --- a/app/bot.go +++ b/app/bot.go @@ -4,6 +4,10 @@ package app import ( + "fmt" + "mime/multipart" + "net/http" + "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/store" @@ -137,7 +141,7 @@ func (a *App) PermanentDeleteBot(botUserId string) *model.AppError { return nil } -// UpdateBotOwner changes a bot's owner to the given value +// UpdateBotOwner changes a bot's owner to the given value. func (a *App) UpdateBotOwner(botUserId, newOwnerId string) (*model.Bot, *model.AppError) { bot, err := a.Srv.Store.Bot().Get(botUserId, true) if err != nil { @@ -154,7 +158,7 @@ func (a *App) UpdateBotOwner(botUserId, newOwnerId string) (*model.Bot, *model.A return bot, nil } -// disableUserBots disables all bots owned by the given user +// disableUserBots disables all bots owned by the given user. func (a *App) disableUserBots(userId string) *model.AppError { perPage := 20 for { @@ -188,7 +192,74 @@ func (a *App) disableUserBots(userId string) *model.AppError { return nil } -// ConvertUserToBot converts a user to bot +// ConvertUserToBot converts a user to bot. func (a *App) ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) { return a.Srv.Store.Bot().Save(model.BotFromUser(user)) } + +// SetBotIconImage sets LHS icon for a bot. +func (a *App) SetBotIconImage(botUserId string, imageData *multipart.FileHeader) *model.AppError { + if len(*a.Config().FileSettings.DriverName) == 0 { + return model.NewAppError("SetBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented) + } + + file, err := imageData.Open() + if err != nil { + return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.open.app_error", nil, err.Error(), http.StatusBadRequest) + } + defer file.Close() + + if _, err = parseSVG(file); err != nil { + return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.parse.app_error", nil, err.Error(), http.StatusBadRequest) + } + + // Set icon + file.Seek(0, 0) + if _, err := a.WriteFile(file, getBotIconPath(botUserId)); err != nil { + return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if err := a.Srv.Store.User().UpdateLastPictureUpdate(botUserId); err != nil { + mlog.Error(err.Error()) + } + a.invalidateUserCacheAndPublish(botUserId) + + return nil +} + +// DeleteBotIconImage deletes LHS icon for a bot. +func (a *App) DeleteBotIconImage(botUserId string) *model.AppError { + if len(*a.Config().FileSettings.DriverName) == 0 { + return model.NewAppError("DeleteBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented) + } + + // Delete icon + if err := a.RemoveFile(getBotIconPath(botUserId)); err != nil { + return model.NewAppError("DeleteBotIconImage", "api.bot.delete_bot_icon_image.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if err := a.Srv.Store.User().UpdateLastPictureUpdate(botUserId); err != nil { + mlog.Error(err.Error()) + } + a.invalidateUserCacheAndPublish(botUserId) + + return nil +} + +// GetBotIconImage retrieves LHS icon for a bot. +func (a *App) GetBotIconImage(botUserId string) ([]byte, bool, *model.AppError) { + if len(*a.Config().FileSettings.DriverName) == 0 { + return nil, false, model.NewAppError("GetBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented) + } + + data, err := a.ReadFile(getBotIconPath(botUserId)) + if err != nil { + return nil, false, model.NewAppError("GetBotIconImage", "api.bot.get_bot_icon_image.read.app_error", nil, err.Error(), http.StatusNotFound) + } + + return data, false, nil +} + +func getBotIconPath(botUserId string) string { + return fmt.Sprintf("bots/%v/icon.svg", botUserId) +} diff --git a/app/user.go b/app/user.go index fadb8819ee..7bbf76c2fb 100644 --- a/app/user.go +++ b/app/user.go @@ -899,21 +899,7 @@ func (a *App) SetProfileImageFromFile(userId string, file io.Reader) *model.AppE if err := a.Srv.Store.User().UpdateLastPictureUpdate(userId); err != nil { mlog.Error(err.Error()) } - - a.InvalidateCacheForUser(userId) - - user, userErr := a.GetUser(userId) - if userErr != nil { - mlog.Error(fmt.Sprintf("Error in getting users profile for id=%v forcing logout", userId), mlog.String("user_id", userId)) - return nil - } - - options := a.Config().GetSanitizeOptions() - user.SanitizeProfile(options) - - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_UPDATED, "", "", "", nil) - message.Add("user", user) - a.Publish(message) + a.invalidateUserCacheAndPublish(userId) return nil } @@ -2227,3 +2213,21 @@ func (a *App) getListOfAllowedChannelsForTeam(teamId string, viewRestrictions *m return listOfAllowedChannels, nil } + +// invalidateUserCacheAndPublish Invalidates cache for a user and publishes user updated event +func (a *App) invalidateUserCacheAndPublish(userId string) { + a.InvalidateCacheForUser(userId) + + user, userErr := a.GetUser(userId) + if userErr != nil { + mlog.Error(fmt.Sprintf("Error in getting users profile for id=%v, err=%v", userId, userErr.Error()), mlog.String("user_id", userId)) + return + } + + options := a.Config().GetSanitizeOptions() + user.SanitizeProfile(options) + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_UPDATED, "", "", "", nil) + message.Add("user", user) + a.Publish(message) +} diff --git a/i18n/en.json b/i18n/en.json index bd7b5928a2..62aa9f6b3d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -135,6 +135,42 @@ "id": "api.bot.create_disabled", "translation": "Bot creation has been disabled." }, + { + "id": "api.bot.delete_bot_icon_image.app_error", + "translation": "Couldn't delete icon image" + }, + { + "id": "api.bot.get_bot_icon_image.read.app_error", + "translation": "Unable to read icon image file" + }, + { + "id": "api.bot.icon_image.storage.app_error", + "translation": "Image storage is not configured." + }, + { + "id": "api.bot.set_bot_icon_image.app_error", + "translation": "Couldn't upload icon image" + }, + { + "id": "api.bot.set_bot_icon_image.array.app_error", + "translation": "Empty array under 'image' in request" + }, + { + "id": "api.bot.set_bot_icon_image.no_file.app_error", + "translation": "No file under 'image' in request" + }, + { + "id": "api.bot.set_bot_icon_image.open.app_error", + "translation": "Could not open image file" + }, + { + "id": "api.bot.set_bot_icon_image.parse.app_error", + "translation": "Could not parse multipart form" + }, + { + "id": "api.bot.set_bot_icon_image.too_large.app_error", + "translation": "Unable to upload icon image. File is too large." + }, { "id": "api.bot.teams_channels.add_message_mobile", "translation": "Please add me to teams and channels you want me to interact in. To do this, use the browser or Mattermost Desktop App." diff --git a/model/client4.go b/model/client4.go index 9514af6c69..96841dde39 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1497,6 +1497,72 @@ func (c *Client4) AssignBot(botUserId, newOwnerId string) (*Bot, *Response) { return BotFromJson(r.Body), BuildResponse(r) } +// SetBotIconImage sets icon image of the user. +func (c *Client4) SetBotIconImage(botUserId string, data []byte) (bool, *Response) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("image", "icon.svg") + if err != nil { + return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.no_file.app_error", nil, err.Error(), http.StatusBadRequest)} + } + + if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil { + return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.no_file.app_error", nil, err.Error(), http.StatusBadRequest)} + } + + if err = writer.Close(); err != nil { + return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.writer.app_error", nil, err.Error(), http.StatusBadRequest)} + } + + rq, err := http.NewRequest("POST", c.ApiUrl+c.GetBotRoute(botUserId)+"/icon", bytes.NewReader(body.Bytes())) + if err != nil { + return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.connecting.app_error", nil, err.Error(), http.StatusBadRequest)} + } + rq.Header.Set("Content-Type", writer.FormDataContentType()) + + if len(c.AuthToken) > 0 { + rq.Header.Set(HEADER_AUTH, c.AuthType+" "+c.AuthToken) + } + + rp, err := c.HttpClient.Do(rq) + if err != nil || rp == nil { + return false, &Response{StatusCode: http.StatusForbidden, Error: NewAppError(c.GetBotRoute(botUserId)+"/icon", "model.client.connecting.app_error", nil, err.Error(), http.StatusForbidden)} + } + defer closeBody(rp) + + if rp.StatusCode >= 300 { + return false, BuildErrorResponse(rp, AppErrorFromJson(rp.Body)) + } + + return CheckStatusOK(rp), BuildResponse(rp) +} + +// GetBotIconImage gets user's LHS icon image. Must be logged in. +func (c *Client4) GetBotIconImage(botUserId string) ([]byte, *Response) { + r, appErr := c.DoApiGet(c.GetBotRoute(botUserId)+"/icon", "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + data, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, BuildErrorResponse(r, NewAppError("GetBotIconImage", "model.client.read_file.app_error", nil, err.Error(), r.StatusCode)) + } + return data, BuildResponse(r) +} + +// DeleteBotIconImage deletes user's LHS icon image. Must be logged in. +func (c *Client4) DeleteBotIconImage(botUserId string) (bool, *Response) { + r, appErr := c.DoApiDelete(c.GetBotRoute(botUserId) + "/icon") + if appErr != nil { + return false, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) +} + // Team Section // CreateTeam creates a team in the system based on the provided team struct. diff --git a/tests/test.svg b/tests/test.svg new file mode 100644 index 0000000000..da82394322 --- /dev/null +++ b/tests/test.svg @@ -0,0 +1,14 @@ + + + + github [#142] + Created with Sketch. + + + + + + + + + \ No newline at end of file