From a0cc913b85dea5023b705697afa5cd8749a6e5de Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 14 Jun 2016 09:38:19 -0400 Subject: PLT-3143 Added serverside code for custom Emoji (#3311) * Added model objects for emoji * Added database tables for emoji * Added settings for custom emoji * Added serverside APIs and unit tests for custom emoji * Added additional validation to catch duplicate emoji names earlier on * Added additional validation to prevent users from adding emoji as another user --- api/api.go | 4 + api/emoji.go | 252 ++++++++++++ api/emoji_test.go | 445 +++++++++++++++++++++ config/config.json | 4 +- i18n/en.json | 112 +++++- model/client.go | 88 ++++ model/config.go | 15 + model/emoji.go | 95 +++++ model/emoji_test.go | 63 +++ store/sql_emoji_store.go | 169 ++++++++ store/sql_emoji_store_test.go | 162 ++++++++ store/sql_store.go | 8 + store/store.go | 9 + utils/config.go | 3 + webapp/components/admin_console/admin_sidebar.jsx | 10 + .../admin_console/custom_emoji_settings.jsx | 91 +++++ webapp/i18n/en.json | 8 + webapp/root.jsx | 5 + 18 files changed, 1538 insertions(+), 5 deletions(-) create mode 100644 api/emoji.go create mode 100644 api/emoji_test.go create mode 100644 model/emoji.go create mode 100644 model/emoji_test.go create mode 100644 store/sql_emoji_store.go create mode 100644 store/sql_emoji_store_test.go create mode 100644 webapp/components/admin_console/custom_emoji_settings.jsx diff --git a/api/api.go b/api/api.go index 3404e0c0b..37172260b 100644 --- a/api/api.go +++ b/api/api.go @@ -46,6 +46,8 @@ type Routes struct { License *mux.Router // 'api/v3/license' Public *mux.Router // 'api/v3/public' + + Emoji *mux.Router // 'api/v3/emoji' } var BaseRoutes *Routes @@ -72,6 +74,7 @@ func InitApi() { BaseRoutes.Preferences = BaseRoutes.ApiRoot.PathPrefix("/preferences").Subrouter() BaseRoutes.License = BaseRoutes.ApiRoot.PathPrefix("/license").Subrouter() BaseRoutes.Public = BaseRoutes.ApiRoot.PathPrefix("/public").Subrouter() + BaseRoutes.Emoji = BaseRoutes.ApiRoot.PathPrefix("/emoji").Subrouter() InitUser() InitTeam() @@ -86,6 +89,7 @@ func InitApi() { InitWebhook() InitPreference() InitLicense() + InitEmoji() // 404 on any api route before web.go has a chance to serve it Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404)) diff --git a/api/emoji.go b/api/emoji.go new file mode 100644 index 000000000..24989924a --- /dev/null +++ b/api/emoji.go @@ -0,0 +1,252 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "bytes" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "mime/multipart" + "net/http" + "strings" + + l4g "github.com/alecthomas/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +const ( + MaxEmojiFileSize = 64 * 1024 // 64 KB + MaxEmojiWidth = 128 + MaxEmojiHeight = 128 +) + +func InitEmoji() { + l4g.Debug(utils.T("api.emoji.init.debug")) + + BaseRoutes.Emoji.Handle("/list", ApiUserRequired(getEmoji)).Methods("GET") + BaseRoutes.Emoji.Handle("/create", ApiUserRequired(createEmoji)).Methods("POST") + BaseRoutes.Emoji.Handle("/delete", ApiUserRequired(deleteEmoji)).Methods("POST") + BaseRoutes.Emoji.Handle("/{id:[A-Za-z0-9_]+}", ApiUserRequired(getEmojiImage)).Methods("GET") +} + +func getEmoji(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.ServiceSettings.EnableCustomEmoji { + c.Err = model.NewLocAppError("getEmoji", "api.emoji.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if result := <-Srv.Store.Emoji().GetAll(); result.Err != nil { + c.Err = result.Err + return + } else { + emoji := result.Data.([]*model.Emoji) + w.Write([]byte(model.EmojiListToJson(emoji))) + } +} + +func createEmoji(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.ServiceSettings.EnableCustomEmoji { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if !(*utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation == model.RESTRICT_EMOJI_CREATION_ALL || c.IsSystemAdmin()) { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.create.permissions.app_error", nil, "user_id="+c.Session.UserId) + c.Err.StatusCode = http.StatusUnauthorized + return + } + + if len(utils.Cfg.FileSettings.DriverName) == 0 { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.storage.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if r.ContentLength > MaxEmojiFileSize { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.create.too_large.app_error", nil, "") + c.Err.StatusCode = http.StatusRequestEntityTooLarge + return + } + + if err := r.ParseMultipartForm(MaxEmojiFileSize); err != nil { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.create.parse.app_error", nil, err.Error()) + c.Err.StatusCode = http.StatusBadRequest + return + } + + m := r.MultipartForm + props := m.Value + + emoji := model.EmojiFromJson(strings.NewReader(props["emoji"][0])) + if emoji == nil { + c.SetInvalidParam("createEmoji", "emoji") + return + } + + // wipe the emoji id so that existing emojis can't get overwritten + emoji.Id = "" + + // do our best to validate the emoji before committing anything to the DB so that we don't have to clean up + // orphaned files left over when validation fails later on + emoji.PreSave() + if err := emoji.IsValid(); err != nil { + c.Err = err + c.Err.StatusCode = http.StatusBadRequest + return + } + + if emoji.CreatorId != c.Session.UserId { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.create.other_user.app_error", nil, "") + c.Err.StatusCode = http.StatusUnauthorized + return + } + + if result := <-Srv.Store.Emoji().GetByName(emoji.Name); result.Err == nil && result.Data != nil { + c.Err = model.NewLocAppError("createEmoji", "api.emoji.create.duplicate.app_error", nil, "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + if imageData := m.File["image"]; len(imageData) == 0 { + c.SetInvalidParam("createEmoji", "image") + return + } else if err := uploadEmojiImage(emoji.Id, imageData[0]); err != nil { + c.Err = err + return + } + + if result := <-Srv.Store.Emoji().Save(emoji); result.Err != nil { + c.Err = result.Err + return + } else { + w.Write([]byte(result.Data.(*model.Emoji).ToJson())) + } +} + +func uploadEmojiImage(id string, imageData *multipart.FileHeader) *model.AppError { + file, err := imageData.Open() + if err != nil { + return model.NewLocAppError("uploadEmojiImage", "api.emoji.upload.open.app_error", nil, "") + } + defer file.Close() + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + // make sure the file is an image and is within the required dimensions + if config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes())); err != nil { + return model.NewLocAppError("uploadEmojiImage", "api.emoji.upload.image.app_error", nil, err.Error()) + } else if config.Width > MaxEmojiWidth || config.Height > MaxEmojiHeight { + return model.NewLocAppError("uploadEmojiImage", "api.emoji.upload.large_image.app_error", nil, "") + } + + if err := WriteFile(buf.Bytes(), getEmojiImagePath(id)); err != nil { + return err + } + + return nil +} + +func deleteEmoji(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.ServiceSettings.EnableCustomEmoji { + c.Err = model.NewLocAppError("deleteEmoji", "api.emoji.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if len(utils.Cfg.FileSettings.DriverName) == 0 { + c.Err = model.NewLocAppError("deleteImage", "api.emoji.storage.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + props := model.MapFromJson(r.Body) + + id := props["id"] + if len(id) == 0 { + c.SetInvalidParam("deleteEmoji", "id") + return + } + + if result := <-Srv.Store.Emoji().Get(id); result.Err != nil { + c.Err = result.Err + return + } else { + if c.Session.UserId != result.Data.(*model.Emoji).CreatorId && !c.IsSystemAdmin() { + c.Err = model.NewLocAppError("deleteEmoji", "api.emoji.delete.permissions.app_error", nil, "user_id="+c.Session.UserId) + c.Err.StatusCode = http.StatusUnauthorized + return + } + } + + if err := (<-Srv.Store.Emoji().Delete(id, model.GetMillis())).Err; err != nil { + c.Err = err + return + } + + go deleteEmojiImage(id) + + ReturnStatusOK(w) +} + +func deleteEmojiImage(id string) { + if err := MoveFile(getEmojiImagePath(id), "emoji/"+id+"/image_deleted"); err != nil { + l4g.Error("Failed to rename image when deleting emoji %v", id) + } +} + +func getEmojiImage(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.ServiceSettings.EnableCustomEmoji { + c.Err = model.NewLocAppError("getEmojiImage", "api.emoji.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if len(utils.Cfg.FileSettings.DriverName) == 0 { + c.Err = model.NewLocAppError("getEmojiImage", "api.emoji.storage.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + params := mux.Vars(r) + + id := params["id"] + if len(id) == 0 { + c.SetInvalidParam("getEmojiImage", "id") + return + } + + if result := <-Srv.Store.Emoji().Get(id); result.Err != nil { + c.Err = result.Err + return + } else { + var img []byte + + if data, err := ReadFile(getEmojiImagePath(id)); err != nil { + c.Err = model.NewLocAppError("getEmojiImage", "api.emoji.get_image.read.app_error", nil, err.Error()) + return + } else { + img = data + } + + if _, imageType, err := image.DecodeConfig(bytes.NewReader(img)); err != nil { + model.NewLocAppError("getEmojiImage", "api.emoji.get_image.decode.app_error", nil, err.Error()) + } else { + w.Header().Set("Content-Type", "image/"+imageType) + } + + w.Write(img) + } +} + +func getEmojiImagePath(id string) string { + return "emoji/" + id + "/image" +} diff --git a/api/emoji_test.go b/api/emoji_test.go new file mode 100644 index 000000000..26dbe9323 --- /dev/null +++ b/api/emoji_test.go @@ -0,0 +1,445 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "testing" + "time" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +func TestGetEmoji(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + emojis := []*model.Emoji{ + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + } + + for i, emoji := range emojis { + emojis[i] = store.Must(Srv.Store.Emoji().Save(emoji)).(*model.Emoji) + } + defer func() { + for _, emoji := range emojis { + store.Must(Srv.Store.Emoji().Delete(emoji.Id, time.Now().Unix())) + } + }() + + if returnedEmojis, err := Client.ListEmoji(); err != nil { + t.Fatal(err) + } else { + for _, emoji := range emojis { + found := false + + for _, savedEmoji := range returnedEmojis { + if emoji.Id == savedEmoji.Id { + found = true + break + } + } + + if !found { + t.Fatalf("failed to get emoji with id %v", emoji.Id) + } + } + } + + deleted := &model.Emoji{ + CreatorId: model.NewId(), + Name: model.NewId(), + DeleteAt: 1, + } + deleted = store.Must(Srv.Store.Emoji().Save(deleted)).(*model.Emoji) + + if returnedEmojis, err := Client.ListEmoji(); err != nil { + t.Fatal(err) + } else { + found := false + + for _, savedEmoji := range returnedEmojis { + if deleted.Id == savedEmoji.Id { + found = true + break + } + } + + if found { + t.Fatalf("souldn't have gotten deleted emoji %v", deleted.Id) + } + } +} + +func TestCreateEmoji(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + + EnableCustomEmoji := *utils.Cfg.ServiceSettings.EnableCustomEmoji + RestrictCustomEmojiCreation := *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation + defer func() { + *utils.Cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji + *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation = RestrictCustomEmojiCreation + }() + *utils.Cfg.ServiceSettings.EnableCustomEmoji = false + *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation = model.RESTRICT_EMOJI_CREATION_ALL + + emoji := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + + // try to create an emoji when they're disabled + if _, err := Client.CreateEmoji(emoji, createTestGif(t, 10, 10), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji when they're disabled") + } + + *utils.Cfg.ServiceSettings.EnableCustomEmoji = true + + // try to create a valid gif emoji when they're enabled + if emojiResult, err := Client.CreateEmoji(emoji, createTestGif(t, 10, 10), "image.gif"); err != nil { + t.Fatal(err) + } else { + emoji = emojiResult + } + + // try to create an emoji with a duplicate name + emoji2 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: emoji.Name, + } + if _, err := Client.CreateEmoji(emoji2, createTestGif(t, 10, 10), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji with a duplicate name") + } + + Client.MustGeneric(Client.DeleteEmoji(emoji.Id)) + + // try to create a valid animated gif emoji + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if emojiResult, err := Client.CreateEmoji(emoji, createTestAnimatedGif(t, 10, 10, 10), "image.gif"); err != nil { + t.Fatal(err) + } else { + emoji = emojiResult + } + Client.MustGeneric(Client.DeleteEmoji(emoji.Id)) + + // try to create a valid jpeg emoji + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if emojiResult, err := Client.CreateEmoji(emoji, createTestJpeg(t, 10, 10), "image.jpeg"); err != nil { + t.Fatal(err) + } else { + emoji = emojiResult + } + Client.MustGeneric(Client.DeleteEmoji(emoji.Id)) + + // try to create a valid png emoji + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if emojiResult, err := Client.CreateEmoji(emoji, createTestPng(t, 10, 10), "image.png"); err != nil { + t.Fatal(err) + } else { + emoji = emojiResult + } + Client.MustGeneric(Client.DeleteEmoji(emoji.Id)) + + // try to create an emoji that's too wide + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, createTestGif(t, 1000, 10), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji that's too wide") + } + + // try to create an emoji that's too tall + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, createTestGif(t, 10, 1000), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji that's too tall") + } + + // try to create an emoji that's too large + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, createTestAnimatedGif(t, 100, 100, 4000), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji that's too large") + } + + // try to create an emoji with data that isn't an image + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, make([]byte, 100, 100), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji with non-image data") + } + + // try to create an emoji as another user + emoji = &model.Emoji{ + CreatorId: th.BasicUser2.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, createTestGif(t, 10, 10), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji as another user") + } + + *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation = model.RESTRICT_EMOJI_CREATION_ADMIN + + // try to create an emoji when only system admins are allowed to create them + emoji = &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + if _, err := Client.CreateEmoji(emoji, createTestGif(t, 10, 10), "image.gif"); err == nil { + t.Fatal("shouldn't be able to create an emoji when not a system admin") + } + + emoji = &model.Emoji{ + CreatorId: th.SystemAdminUser.Id, + Name: model.NewId(), + } + if emojiResult, err := th.SystemAdminClient.CreateEmoji(emoji, createTestPng(t, 10, 10), "image.png"); err != nil { + t.Fatal(err) + } else { + emoji = emojiResult + } + th.SystemAdminClient.MustGeneric(th.SystemAdminClient.DeleteEmoji(emoji.Id)) +} + +func TestDeleteEmoji(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + + EnableCustomEmoji := *utils.Cfg.ServiceSettings.EnableCustomEmoji + defer func() { + *utils.Cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji + }() + *utils.Cfg.ServiceSettings.EnableCustomEmoji = false + + emoji1 := createTestEmoji(t, &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + }, createTestGif(t, 10, 10)) + + if _, err := Client.DeleteEmoji(emoji1.Id); err == nil { + t.Fatal("shouldn't have been able to delete an emoji when they're disabled") + } + + *utils.Cfg.ServiceSettings.EnableCustomEmoji = true + + if deleted, err := Client.DeleteEmoji(emoji1.Id); err != nil { + t.Fatal(err) + } else if !deleted { + t.Fatalf("should be able to delete your own emoji %v", emoji1.Id) + } + + if _, err := Client.DeleteEmoji(emoji1.Id); err == nil { + t.Fatal("shouldn't be able to delete an already-deleted emoji") + } + + emoji2 := createTestEmoji(t, &model.Emoji{ + CreatorId: th.BasicUser2.Id, + Name: model.NewId(), + }, createTestGif(t, 10, 10)) + + if _, err := Client.DeleteEmoji(emoji2.Id); err == nil { + t.Fatal("shouldn't be able to delete another user's emoji") + } + + if deleted, err := th.SystemAdminClient.DeleteEmoji(emoji2.Id); err != nil { + t.Fatal(err) + } else if !deleted { + t.Fatalf("system admin should be able to delete anyone's emoji %v", emoji2.Id) + } +} + +func createTestGif(t *testing.T, width int, height int) []byte { + var buffer bytes.Buffer + + if err := gif.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height)), nil); err != nil { + t.Fatalf("failed to create gif: %v", err.Error()) + } + + return buffer.Bytes() +} + +func createTestAnimatedGif(t *testing.T, width int, height int, frames int) []byte { + var buffer bytes.Buffer + + img := gif.GIF{ + Image: make([]*image.Paletted, frames, frames), + Delay: make([]int, frames, frames), + } + for i := 0; i < frames; i++ { + img.Image[i] = image.NewPaletted(image.Rect(0, 0, width, height), color.Palette{color.Black}) + img.Delay[i] = 0 + } + if err := gif.EncodeAll(&buffer, &img); err != nil { + t.Fatalf("failed to create animated gif: %v", err.Error()) + } + + return buffer.Bytes() +} + +func createTestJpeg(t *testing.T, width int, height int) []byte { + var buffer bytes.Buffer + + if err := jpeg.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height)), nil); err != nil { + t.Fatalf("failed to create jpeg: %v", err.Error()) + } + + return buffer.Bytes() +} + +func createTestPng(t *testing.T, width int, height int) []byte { + var buffer bytes.Buffer + + if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil { + t.Fatalf("failed to create png: %v", err.Error()) + } + + return buffer.Bytes() +} + +func createTestEmoji(t *testing.T, emoji *model.Emoji, imageData []byte) *model.Emoji { + emoji = store.Must(Srv.Store.Emoji().Save(emoji)).(*model.Emoji) + + if err := WriteFile(imageData, "emoji/"+emoji.Id+"/image"); err != nil { + store.Must(Srv.Store.Emoji().Delete(emoji.Id, time.Now().Unix())) + t.Fatalf("failed to write image: %v", err.Error()) + } + + return emoji +} + +func TestGetEmojiImage(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + EnableCustomEmoji := *utils.Cfg.ServiceSettings.EnableCustomEmoji + RestrictCustomEmojiCreation := *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation + defer func() { + *utils.Cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji + *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation = RestrictCustomEmojiCreation + }() + *utils.Cfg.ServiceSettings.EnableCustomEmoji = true + *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation = model.RESTRICT_EMOJI_CREATION_ALL + + emoji1 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + emoji1 = Client.MustGeneric(Client.CreateEmoji(emoji1, createTestGif(t, 10, 10), "image.gif")).(*model.Emoji) + defer func() { Client.MustGeneric(Client.DeleteEmoji(emoji1.Id)) }() + + *utils.Cfg.ServiceSettings.EnableCustomEmoji = false + + if _, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji1.Id), "", ""); err == nil { + t.Fatal("should've failed to get emoji image when disabled") + } + + *utils.Cfg.ServiceSettings.EnableCustomEmoji = true + + if resp, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji1.Id), "", ""); err != nil { + t.Fatal(err) + } else if resp.Header.Get("Content-Type") != "image/gif" { + t.Fatal("should've received a gif") + } else if _, imageType, err := image.DecodeConfig(resp.Body); err != nil { + t.Fatalf("unable to identify received image: %v", err.Error()) + } else if imageType != "gif" { + t.Fatal("should've received gif data") + } + + emoji2 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + emoji2 = Client.MustGeneric(Client.CreateEmoji(emoji2, createTestAnimatedGif(t, 10, 10, 10), "image.gif")).(*model.Emoji) + defer func() { Client.MustGeneric(Client.DeleteEmoji(emoji2.Id)) }() + + if resp, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji2.Id), "", ""); err != nil { + t.Fatal(err) + } else if resp.Header.Get("Content-Type") != "image/gif" { + t.Fatal("should've received a gif") + } else if _, imageType, err := image.DecodeConfig(resp.Body); err != nil { + t.Fatalf("unable to identify received image: %v", err.Error()) + } else if imageType != "gif" { + t.Fatal("should've received gif data") + } + + emoji3 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + emoji3 = Client.MustGeneric(Client.CreateEmoji(emoji3, createTestJpeg(t, 10, 10), "image.jpeg")).(*model.Emoji) + defer func() { Client.MustGeneric(Client.DeleteEmoji(emoji3.Id)) }() + + if resp, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji3.Id), "", ""); err != nil { + t.Fatal(err) + } else if resp.Header.Get("Content-Type") != "image/jpeg" { + t.Fatal("should've received a jpeg") + } else if _, imageType, err := image.DecodeConfig(resp.Body); err != nil { + t.Fatalf("unable to identify received image: %v", err.Error()) + } else if imageType != "jpeg" { + t.Fatal("should've received jpeg data") + } + + emoji4 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + emoji4 = Client.MustGeneric(Client.CreateEmoji(emoji4, createTestPng(t, 10, 10), "image.png")).(*model.Emoji) + defer func() { Client.MustGeneric(Client.DeleteEmoji(emoji4.Id)) }() + + if resp, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji4.Id), "", ""); err != nil { + t.Fatal(err) + } else if resp.Header.Get("Content-Type") != "image/png" { + t.Fatal("should've received a png") + } else if _, imageType, err := image.DecodeConfig(resp.Body); err != nil { + t.Fatalf("unable to identify received image: %v", err.Error()) + } else if imageType != "png" { + t.Fatal("should've received png data") + } + + emoji5 := &model.Emoji{ + CreatorId: th.BasicUser.Id, + Name: model.NewId(), + } + emoji5 = Client.MustGeneric(Client.CreateEmoji(emoji5, createTestPng(t, 10, 10), "image.png")).(*model.Emoji) + Client.MustGeneric(Client.DeleteEmoji(emoji5.Id)) + + if _, err := Client.DoApiGet(Client.GetCustomEmojiImageUrl(emoji5.Id), "", ""); err == nil { + t.Fatal("should've failed to get image for deleted emoji") + } +} diff --git a/config/config.json b/config/config.json index db62b0bac..13936ee9e 100644 --- a/config/config.json +++ b/config/config.json @@ -23,7 +23,9 @@ "SessionCacheInMinutes": 10, "WebsocketSecurePort": 443, "WebsocketPort": 80, - "WebserverMode": "regular" + "WebserverMode": "regular", + "EnableCustomEmoji": true, + "RestrictCustomEmojiCreation": "all" }, "TeamSettings": { "SiteName": "Mattermost", diff --git a/i18n/en.json b/i18n/en.json index 0f24ff03a..ddadc6e6f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -51,10 +51,6 @@ "id": "system.message.name", "translation": "System" }, - { - "id": "api.general.init.debug", - "translation": "Initializing general api routes" - }, { "id": "api.admin.file_read_error", "translation": "Error reading log file" @@ -579,6 +575,54 @@ "id": "api.context.unknown.app_error", "translation": "An unknown error has occurred. Please contact support." }, + { + "id": "api.emoji.create.duplicate.app_error", + "translation": "Unable to create emoji. Another emoji with the same name already exists." + }, + { + "id": "api.emoji.create.parse.app_error", + "translation": "Unable to create emoji. Image exceeds maximum file size." + }, + { + "id": "api.emoji.create.permissions.app_error", + "translation": "Inappropriate permissions to create emoji." + }, + { + "id": "api.emoji.create.too_large.app_error", + "translation": "Unable to create emoji. Could not understand request." + }, + { + "id": "api.emoji.delete.permissions.app_error", + "translation": "Inappropriate permissions to delete emoji." + }, + { + "id": "api.emoji.disabled.app_error", + "translation": "Custom emoji have been disabled by the system admin." + }, + { + "id": "api.emoji.get_image.decode.app_error", + "translation": "Unable to decode image file for emoji." + }, + { + "id": "api.emoji.get_image.read.app_error", + "translation": "Unable to read image file for emoji." + }, + { + "id": "api.emoji.storage.app_error", + "translation": "File storage not configured properly. Please configure for either S3 or local server file storage." + }, + { + "id": "api.emoji.upload.image.app_error", + "translation": "Unable to create emoji. File must be a PNG, JPEG, or GIF." + }, + { + "id": "api.emoji.upload.large_image.app_error", + "translation": "Unable to create emoji. Image exceeds maximum dimensions." + }, + { + "id": "api.emoji.init.debug", + "translation": "Initializing emoji api routes" + }, { "id": "api.export.json.app_error", "translation": "Unable to convert to json" @@ -743,6 +787,10 @@ "id": "api.file.write_file_locally.writing.app_error", "translation": "Encountered an error writing to local server storage" }, + { + "id": "api.general.init.debug", + "translation": "Initializing general api routes" + }, { "id": "api.import.import_post.saving.debug", "translation": "Error saving post. user=%v, message=%v" @@ -2323,6 +2371,18 @@ "id": "model.client.connecting.app_error", "translation": "We encountered an error while connecting to the server" }, + { + "id": "model.client.create_emoji.emoji.app_error", + "translation": "Unable to attach emoji data to request" + }, + { + "id": "model.client.create_emoji.image.app_error", + "translation": "Unable to attach image to request" + }, + { + "id": "model.client.create_emoji.writer.app_error", + "translation": "Unable to write request" + }, { "id": "model.client.login.app_error", "translation": "Authentication tokens didn't match" @@ -2499,6 +2559,26 @@ "id": "model.config.is_valid.sql_max_conn.app_error", "translation": "Invalid maximum open connection for SQL settings. Must be a positive number." }, + { + "id": "model.emoji.create_at.app_error", + "translation": "Create at must be a valid time" + }, + { + "id": "model.emoji.creator_id.app_error", + "translation": "Invalid creator id" + }, + { + "id": "model.emoji.id.app_error", + "translation": "Invalid emoji id" + }, + { + "id": "model.emoji.name.app_error", + "translation": "Name must be 1 to 64 lowercase alphanumeric characters" + }, + { + "id": "model.emoji.update_at.app_error", + "translation": "Update at must be a valid time" + }, { "id": "model.file_info.get.gif.app_error", "translation": "Could not decode gif." @@ -3195,6 +3275,30 @@ "id": "store.sql_compliance.save.saving.app_error", "translation": "We encountered an error saving the compliance report" }, + { + "id": "store.sql_emoji.delete.app_error", + "translation": "We couldn't delete the emoji" + }, + { + "id": "store.sql_emoji.delete.no_results", + "translation": "We couldn’t find the emoji to delete" + }, + { + "id": "store.sql_emoji.get.app_error", + "translation": "We couldn't get the emoji" + }, + { + "id": "store.sql_emoji.get_by_name.app_error", + "translation": "We couldn't get the emoji" + }, + { + "id": "store.sql_emoji.get_all.app_error", + "translation": "We couldn't get the emoji" + }, + { + "id": "store.sql_emoji.save.app_error", + "translation": "We couldn't save the emoji" + }, { "id": "store.sql_license.get.app_error", "translation": "We encountered an error getting the license" diff --git a/model/client.go b/model/client.go index e8ce21ab0..80ab42dc4 100644 --- a/model/client.go +++ b/model/client.go @@ -7,7 +7,9 @@ import ( "bytes" "fmt" l4g "github.com/alecthomas/log4go" + "io" "io/ioutil" + "mime/multipart" "net/http" "net/url" "strconv" @@ -106,6 +108,10 @@ func (c *Client) GetChannelNameRoute(channelName string) string { return fmt.Sprintf("/teams/%v/channels/name/%v", c.GetTeamId(), channelName) } +func (c *Client) GetEmojiRoute() string { + return "/emoji" +} + func (c *Client) GetGeneralRoute() string { return "/general" } @@ -185,6 +191,17 @@ func (c *Client) Must(result *Result, err *AppError) *Result { return result } +// MustGeneric is a convenience function used for testing. +func (c *Client) MustGeneric(result interface{}, err *AppError) interface{} { + if err != nil { + l4g.Close() + time.Sleep(time.Second) + panic(err) + } + + return result +} + // CheckStatusOK is a convenience function for checking the return of Web Service // call that return the a map of status=OK. func (c *Client) CheckStatusOK(r *http.Response) bool { @@ -1509,3 +1526,74 @@ func (c *Client) GetInitialLoad() (*Result, *AppError) { r.Header.Get(HEADER_ETAG_SERVER), InitialLoadFromJson(r.Body)}, nil } } + +// ListEmoji returns a list of all user-created emoji for the server. +func (c *Client) ListEmoji() ([]*Emoji, *AppError) { + if r, err := c.DoApiGet(c.GetEmojiRoute()+"/list", "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + c.fillInExtraProperties(r) + return EmojiListFromJson(r.Body), nil + } +} + +// CreateEmoji will save an emoji to the server if the current user has permission +// to do so. If successful, the provided emoji will be returned with its Id field +// filled in. Otherwise, an error will be returned. +func (c *Client) CreateEmoji(emoji *Emoji, image []byte, filename string) (*Emoji, *AppError) { + c.clearExtraProperties() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + if part, err := writer.CreateFormFile("image", filename); err != nil { + return nil, NewLocAppError("CreateEmoji", "model.client.create_emoji.image.app_error", nil, err.Error()) + } else if _, err = io.Copy(part, bytes.NewBuffer(image)); err != nil { + return nil, NewLocAppError("CreateEmoji", "model.client.create_emoji.image.app_error", nil, err.Error()) + } + + if err := writer.WriteField("emoji", emoji.ToJson()); err != nil { + return nil, NewLocAppError("CreateEmoji", "model.client.create_emoji.emoji.app_error", nil, err.Error()) + } + + if err := writer.Close(); err != nil { + return nil, NewLocAppError("CreateEmoji", "model.client.create_emoji.writer.app_error", nil, err.Error()) + } + + rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetEmojiRoute()+"/create", body) + rq.Header.Set("Content-Type", writer.FormDataContentType()) + + if len(c.AuthToken) > 0 { + rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) + } + + if r, err := c.HttpClient.Do(rq); err != nil { + return nil, NewLocAppError("CreateEmoji", "model.client.connecting.app_error", nil, err.Error()) + } else if r.StatusCode >= 300 { + return nil, AppErrorFromJson(r.Body) + } else { + defer closeBody(r) + c.fillInExtraProperties(r) + return EmojiFromJson(r.Body), nil + } +} + +// DeleteEmoji will delete an emoji from the server if the current user has permission +// to do so. If successful, it will return status=ok. Otherwise, an error will be returned. +func (c *Client) DeleteEmoji(id string) (bool, *AppError) { + data := map[string]string{"id": id} + + if r, err := c.DoApiPost(c.GetEmojiRoute()+"/delete", MapToJson(data)); err != nil { + return false, err + } else { + c.fillInExtraProperties(r) + return c.CheckStatusOK(r), nil + } +} + +// GetCustomEmojiImageUrl returns the API route that can be used to get the image used by +// the given emoji. +func (c *Client) GetCustomEmojiImageUrl(id string) string { + return c.GetEmojiRoute() + "/" + id +} diff --git a/model/config.go b/model/config.go index 08510fc44..f99b0454e 100644 --- a/model/config.go +++ b/model/config.go @@ -34,6 +34,9 @@ const ( DIRECT_MESSAGE_TEAM = "team" FAKE_SETTING = "********************************" + + RESTRICT_EMOJI_CREATION_ALL = "all" + RESTRICT_EMOJI_CREATION_ADMIN = "system_admin" ) // should match the values in webapp/i18n/i18n.jsx @@ -70,6 +73,8 @@ type ServiceSettings struct { WebsocketSecurePort *int WebsocketPort *int WebserverMode *string + EnableCustomEmoji *bool + RestrictCustomEmojiCreation *string } type SSOSettings struct { @@ -565,6 +570,16 @@ func (o *Config) SetDefaults() { *o.ServiceSettings.WebserverMode = "regular" } + if o.ServiceSettings.EnableCustomEmoji == nil { + o.ServiceSettings.EnableCustomEmoji = new(bool) + *o.ServiceSettings.EnableCustomEmoji = true + } + + if o.ServiceSettings.RestrictCustomEmojiCreation == nil { + o.ServiceSettings.RestrictCustomEmojiCreation = new(string) + *o.ServiceSettings.RestrictCustomEmojiCreation = RESTRICT_EMOJI_CREATION_ALL + } + if o.ComplianceSettings.Enable == nil { o.ComplianceSettings.Enable = new(bool) *o.ComplianceSettings.Enable = false diff --git a/model/emoji.go b/model/emoji.go new file mode 100644 index 000000000..a66053aa0 --- /dev/null +++ b/model/emoji.go @@ -0,0 +1,95 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type Emoji struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + CreatorId string `json:"creator_id"` + Name string `json:"name"` +} + +func (emoji *Emoji) IsValid() *AppError { + if len(emoji.Id) != 26 { + return NewLocAppError("Emoji.IsValid", "model.emoji.id.app_error", nil, "") + } + + if emoji.CreateAt == 0 { + return NewLocAppError("Emoji.IsValid", "model.emoji.create_at.app_error", nil, "id="+emoji.Id) + } + + if emoji.UpdateAt == 0 { + return NewLocAppError("Emoji.IsValid", "model.emoji.update_at.app_error", nil, "id="+emoji.Id) + } + + if len(emoji.CreatorId) != 26 { + return NewLocAppError("Emoji.IsValid", "model.emoji.user_id.app_error", nil, "") + } + + if len(emoji.Name) == 0 || len(emoji.Name) > 64 { + return NewLocAppError("Emoji.IsValid", "model.emoji.name.app_error", nil, "") + } + + return nil +} + +func (emoji *Emoji) PreSave() { + if emoji.Id == "" { + emoji.Id = NewId() + } + + emoji.CreateAt = GetMillis() + emoji.UpdateAt = emoji.CreateAt +} + +func (emoji *Emoji) PreUpdate() { + emoji.UpdateAt = GetMillis() +} + +func (emoji *Emoji) ToJson() string { + b, err := json.Marshal(emoji) + if err != nil { + return "" + } else { + return string(b) + } +} + +func EmojiFromJson(data io.Reader) *Emoji { + decoder := json.NewDecoder(data) + var emoji Emoji + err := decoder.Decode(&emoji) + if err == nil { + return &emoji + } else { + return nil + } +} + +func EmojiListToJson(emojiList []*Emoji) string { + b, err := json.Marshal(emojiList) + if err != nil { + return "" + } else { + return string(b) + } +} + +func EmojiListFromJson(data io.Reader) []*Emoji { + decoder := json.NewDecoder(data) + var emojiList []*Emoji + err := decoder.Decode(&emojiList) + if err == nil { + return emojiList + } else { + return nil + } +} diff --git a/model/emoji_test.go b/model/emoji_test.go new file mode 100644 index 000000000..cd6344ca5 --- /dev/null +++ b/model/emoji_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestEmojiIsValid(t *testing.T) { + emoji := Emoji{ + Id: NewId(), + CreateAt: 1234, + UpdateAt: 1234, + DeleteAt: 0, + CreatorId: NewId(), + Name: "name", + } + + if err := emoji.IsValid(); err != nil { + t.Fatal(err) + } + + emoji.Id = "1234" + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.Id = NewId() + emoji.CreateAt = 0 + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.CreateAt = 1234 + emoji.UpdateAt = 0 + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.UpdateAt = 1234 + emoji.CreatorId = strings.Repeat("1", 25) + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.CreatorId = strings.Repeat("1", 27) + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.CreatorId = NewId() + emoji.Name = strings.Repeat("1", 65) + if err := emoji.IsValid(); err == nil { + t.Fatal() + } + + emoji.Name = strings.Repeat("1", 64) + if err := emoji.IsValid(); err != nil { + t.Fatal(err) + } +} diff --git a/store/sql_emoji_store.go b/store/sql_emoji_store.go new file mode 100644 index 000000000..99434ee64 --- /dev/null +++ b/store/sql_emoji_store.go @@ -0,0 +1,169 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" +) + +type SqlEmojiStore struct { + *SqlStore +} + +func NewSqlEmojiStore(sqlStore *SqlStore) EmojiStore { + s := &SqlEmojiStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Emoji{}, "Emoji").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("CreatorId").SetMaxSize(26) + table.ColMap("Name").SetMaxSize(64) + + table.SetUniqueTogether("Name", "DeleteAt") + } + + return s +} + +func (es SqlEmojiStore) UpgradeSchemaIfNeeded() { +} + +func (es SqlEmojiStore) CreateIndexesIfNotExists() { +} + +func (es SqlEmojiStore) Save(emoji *model.Emoji) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + emoji.PreSave() + if result.Err = emoji.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := es.GetMaster().Insert(emoji); err != nil { + result.Err = model.NewLocAppError("SqlEmojiStore.Save", "store.sql_emoji.save.app_error", nil, "id="+emoji.Id+", "+err.Error()) + } else { + result.Data = emoji + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (es SqlEmojiStore) Get(id string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var emoji *model.Emoji + + if err := es.GetReplica().SelectOne(&emoji, + `SELECT + * + FROM + Emoji + WHERE + Id = :Id + AND DeleteAt = 0`, map[string]interface{}{"Id": id}); err != nil { + result.Err = model.NewLocAppError("SqlEmojiStore.Get", "store.sql_emoji.get.app_error", nil, "id="+id+", "+err.Error()) + } else { + result.Data = emoji + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (es SqlEmojiStore) GetByName(name string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var emoji *model.Emoji + + if err := es.GetReplica().SelectOne(&emoji, + `SELECT + * + FROM + Emoji + WHERE + Name = :Name + AND DeleteAt = 0`, map[string]interface{}{"Name": name}); err != nil { + result.Err = model.NewLocAppError("SqlEmojiStore.GetByName", "store.sql_emoji.get_by_name.app_error", nil, "name="+name+", "+err.Error()) + } else { + result.Data = emoji + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (es SqlEmojiStore) GetAll() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var emoji []*model.Emoji + + if _, err := es.GetReplica().Select(&emoji, + `SELECT + * + FROM + Emoji + WHERE + DeleteAt = 0`); err != nil { + result.Err = model.NewLocAppError("SqlEmojiStore.Get", "store.sql_emoji.get_all.app_error", nil, err.Error()) + } else { + result.Data = emoji + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (es SqlEmojiStore) Delete(id string, time int64) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if sqlResult, err := es.GetMaster().Exec( + `Update + Emoji + SET + DeleteAt = :DeleteAt, + UpdateAt = :UpdateAt + WHERE + Id = :Id + AND DeleteAt = 0`, map[string]interface{}{"DeleteAt": time, "UpdateAt": time, "Id": id}); err != nil { + result.Err = model.NewLocAppError("SqlEmojiStore.Delete", "store.sql_emoji.delete.app_error", nil, "id="+id+", err="+err.Error()) + } else if rows, _ := sqlResult.RowsAffected(); rows == 0 { + result.Err = model.NewLocAppError("SqlEmojiStore.Delete", "store.sql_emoji.delete.no_results", nil, "id="+id+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_emoji_store_test.go b/store/sql_emoji_store_test.go new file mode 100644 index 000000000..f9c42c906 --- /dev/null +++ b/store/sql_emoji_store_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" + "time" +) + +func TestEmojiSaveDelete(t *testing.T) { + Setup() + + emoji1 := &model.Emoji{ + CreatorId: model.NewId(), + Name: model.NewId(), + } + + if result := <-store.Emoji().Save(emoji1); result.Err != nil { + t.Fatal(result.Err) + } + + if len(emoji1.Id) != 26 { + t.Fatal("should've set id for emoji") + } + + emoji2 := model.Emoji{ + CreatorId: model.NewId(), + Name: emoji1.Name, + } + if result := <-store.Emoji().Save(&emoji2); result.Err == nil { + t.Fatal("shouldn't be able to save emoji with duplicate name") + } + + if result := <-store.Emoji().Delete(emoji1.Id, time.Now().Unix()); result.Err != nil { + t.Fatal(result.Err) + } + + if result := <-store.Emoji().Save(&emoji2); result.Err != nil { + t.Fatal("should be able to save emoji with duplicate name now that original has been deleted", result.Err) + } + + if result := <-store.Emoji().Delete(emoji2.Id, time.Now().Unix()+1); result.Err != nil { + t.Fatal(result.Err) + } +} + +func TestEmojiGet(t *testing.T) { + Setup() + + emojis := []model.Emoji{ + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + } + + for i, emoji := range emojis { + emojis[i] = *Must(store.Emoji().Save(&emoji)).(*model.Emoji) + } + defer func() { + for _, emoji := range emojis { + Must(store.Emoji().Delete(emoji.Id, time.Now().Unix())) + } + }() + + for _, emoji := range emojis { + if result := <-store.Emoji().Get(emoji.Id); result.Err != nil { + t.Fatalf("failed to get emoji with id %v: %v", emoji.Id, result.Err) + } + } +} + +func TestEmojiGetByName(t *testing.T) { + Setup() + + emojis := []model.Emoji{ + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + } + + for i, emoji := range emojis { + emojis[i] = *Must(store.Emoji().Save(&emoji)).(*model.Emoji) + } + defer func() { + for _, emoji := range emojis { + Must(store.Emoji().Delete(emoji.Id, time.Now().Unix())) + } + }() + + for _, emoji := range emojis { + if result := <-store.Emoji().GetByName(emoji.Name); result.Err != nil { + t.Fatalf("failed to get emoji with name %v: %v", emoji.Name, result.Err) + } + } +} + +func TestEmojiGetAll(t *testing.T) { + Setup() + + emojis := []model.Emoji{ + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + } + + for i, emoji := range emojis { + emojis[i] = *Must(store.Emoji().Save(&emoji)).(*model.Emoji) + } + defer func() { + for _, emoji := range emojis { + Must(store.Emoji().Delete(emoji.Id, time.Now().Unix())) + } + }() + + if result := <-store.Emoji().GetAll(); result.Err != nil { + t.Fatal(result.Err) + } else { + for _, emoji := range emojis { + found := false + + for _, savedEmoji := range result.Data.([]*model.Emoji) { + if emoji.Id == savedEmoji.Id { + found = true + break + } + } + + if !found { + t.Fatalf("failed to get emoji with id %v", emoji.Id) + } + } + } +} diff --git a/store/sql_store.go b/store/sql_store.go index e45d1ef94..c33da62cc 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -52,6 +52,7 @@ type SqlStore struct { preference PreferenceStore license LicenseStore recovery PasswordRecoveryStore + emoji EmojiStore SchemaVersion string } @@ -127,6 +128,7 @@ func NewSqlStore() Store { sqlStore.preference = NewSqlPreferenceStore(sqlStore) sqlStore.license = NewSqlLicenseStore(sqlStore) sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore) + sqlStore.emoji = NewSqlEmojiStore(sqlStore) err := sqlStore.master.CreateTablesIfNotExists() if err != nil { @@ -149,6 +151,7 @@ func NewSqlStore() Store { sqlStore.preference.(*SqlPreferenceStore).UpgradeSchemaIfNeeded() sqlStore.license.(*SqlLicenseStore).UpgradeSchemaIfNeeded() sqlStore.recovery.(*SqlPasswordRecoveryStore).UpgradeSchemaIfNeeded() + sqlStore.emoji.(*SqlEmojiStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -164,6 +167,7 @@ func NewSqlStore() Store { sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists() sqlStore.license.(*SqlLicenseStore).CreateIndexesIfNotExists() sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists() + sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists() sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() @@ -688,6 +692,10 @@ func (ss SqlStore) PasswordRecovery() PasswordRecoveryStore { return ss.recovery } +func (ss SqlStore) Emoji() EmojiStore { + return ss.emoji +} + func (ss SqlStore) DropAllTables() { ss.master.TruncateTables() } diff --git a/store/store.go b/store/store.go index 7f4db396c..29a7e8d82 100644 --- a/store/store.go +++ b/store/store.go @@ -42,6 +42,7 @@ type Store interface { Preference() PreferenceStore License() LicenseStore PasswordRecovery() PasswordRecoveryStore + Emoji() EmojiStore MarkSystemRanUnitTests() Close() DropAllTables() @@ -254,3 +255,11 @@ type PasswordRecoveryStore interface { Get(userId string) StoreChannel GetByCode(code string) StoreChannel } + +type EmojiStore interface { + Save(emoji *model.Emoji) StoreChannel + Get(id string) StoreChannel + GetByName(name string) StoreChannel + GetAll() StoreChannel + Delete(id string, time int64) StoreChannel +} diff --git a/utils/config.go b/utils/config.go index 9700f44e0..f3b62a25a 100644 --- a/utils/config.go +++ b/utils/config.go @@ -251,6 +251,9 @@ func getClientConfig(c *model.Config) map[string]string { props["AvailableLocales"] = *c.LocalizationSettings.AvailableLocales props["SQLDriverName"] = c.SqlSettings.DriverName + props["EnableCustomEmoji"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomEmoji) + props["RestrictCustomEmojiCreation"] = *c.ServiceSettings.RestrictCustomEmojiCreation + if IsLicensed { if *License.Features.CustomBrand { props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand) diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index c947be5cb..28769d484 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -532,6 +532,16 @@ export default class AdminSidebar extends React.Component { } > {customBranding} + + + } + /> + + + ); + } + + renderSettings() { + return ( + + + } + helpText={ + + } + value={this.state.enableCustomEmoji} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.restrictCustomEmojiCreation} + onChange={this.handleChange} + disabled={!this.state.enableCustomEmoji} + /> + + ); + } +} \ No newline at end of file diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 85f221c7c..b1ab4964f 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -125,6 +125,13 @@ "admin.connectionSecurityTitle": "Connection Security:", "admin.connectionSecurityTls": "TLS", "admin.connectionSecurityTlsDescription": "Encrypts the communication between Mattermost and your server.", + "admin.customization.customEmoji": "Custom Emoji", + "admin.customization.enableCustomEmojiDesc": "Enable users to create custom emoji for use in chat messages.", + "admin.customization.enableCustomEmojiTitle": "Enable Custom Emoji:", + "admin.customization.restrictCustomEmojiCreationAll": "Allow everyone to create custom emoji", + "admin.customization.restrictCustomEmojiCreationDesc": "Restrict the creation of custom emoji to certain users.", + "admin.customization.restrictCustomEmojiCreationSystemAdmin": "Only allow system admins to create custom emoji", + "admin.customization.restrictCustomEmojiCreationTitle": "Restrict Custom Emoji Creation:", "admin.email.agreeHPNS": " I understand and accept the Mattermost Hosted Push Notification Service Terms of Service and Privacy Policy.", "admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.", "admin.email.allowEmailSignInTitle": "Allow Sign In With Email: ", @@ -422,6 +429,7 @@ "admin.sidebar.configuration": "Configuration", "admin.sidebar.connections": "Connections", "admin.sidebar.customBrand": "Custom Branding", + "admin.sidebar.customEmoji": "Custom Emoji", "admin.sidebar.customization": "Customization", "admin.sidebar.database": "Database", "admin.sidebar.developer": "Developer", diff --git a/webapp/root.jsx b/webapp/root.jsx index a96ab713b..b6302a76f 100644 --- a/webapp/root.jsx +++ b/webapp/root.jsx @@ -73,6 +73,7 @@ import DatabaseSettings from 'components/admin_console/database_settings.jsx'; import StorageSettings from 'components/admin_console/storage_settings.jsx'; import ImageSettings from 'components/admin_console/image_settings.jsx'; import CustomBrandSettings from 'components/admin_console/custom_brand_settings.jsx'; +import CustomEmojiSettings from 'components/admin_console/custom_emoji_settings.jsx'; import LegalAndSupportSettings from 'components/admin_console/legal_and_support_settings.jsx'; import ComplianceSettings from 'components/admin_console/compliance_settings.jsx'; import RateSettings from 'components/admin_console/rate_settings.jsx'; @@ -461,6 +462,10 @@ function renderRootComponent() { path='custom_brand' component={CustomBrandSettings} /> +