summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorHarrison Healey <harrisonmhealey@gmail.com>2016-06-14 09:38:19 -0400
committerChristopher Speller <crspeller@gmail.com>2016-06-14 07:38:19 -0600
commita0cc913b85dea5023b705697afa5cd8749a6e5de (patch)
treedebe3365ea1e66e94bd0a4738bf4faa0f10eac05
parent661f221727109f2298812fea89347bfeaf984109 (diff)
downloadchat-a0cc913b85dea5023b705697afa5cd8749a6e5de.tar.gz
chat-a0cc913b85dea5023b705697afa5cd8749a6e5de.tar.bz2
chat-a0cc913b85dea5023b705697afa5cd8749a6e5de.zip
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
-rw-r--r--api/api.go4
-rw-r--r--api/emoji.go252
-rw-r--r--api/emoji_test.go445
-rw-r--r--config/config.json4
-rw-r--r--i18n/en.json112
-rw-r--r--model/client.go88
-rw-r--r--model/config.go15
-rw-r--r--model/emoji.go95
-rw-r--r--model/emoji_test.go63
-rw-r--r--store/sql_emoji_store.go169
-rw-r--r--store/sql_emoji_store_test.go162
-rw-r--r--store/sql_store.go8
-rw-r--r--store/store.go9
-rw-r--r--utils/config.go3
-rw-r--r--webapp/components/admin_console/admin_sidebar.jsx10
-rw-r--r--webapp/components/admin_console/custom_emoji_settings.jsx91
-rw-r--r--webapp/i18n/en.json8
-rw-r--r--webapp/root.jsx5
18 files changed, 1538 insertions, 5 deletions
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
@@ -52,10 +52,6 @@
"translation": "System"
},
{
- "id": "api.general.init.debug",
- "translation": "Initializing general api routes"
- },
- {
"id": "api.admin.file_read_error",
"translation": "Error reading log file"
},
@@ -580,6 +576,54 @@
"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"
},
@@ -744,6 +788,10 @@
"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"
},
@@ -2324,6 +2372,18 @@
"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"
},
@@ -2500,6 +2560,26 @@
"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."
},
@@ -3196,6 +3276,30 @@
"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
@@ -533,6 +533,16 @@ export default class AdminSidebar extends React.Component {
>
{customBranding}
<AdminSidebarSection
+ name='custom_emoji'
+ title={
+ <FormattedMessage
+ id='admin.sidebar.customEmoji'
+ defaultMessage='Custom Emoji'
+ />
+
+ }
+ />
+ <AdminSidebarSection
name='legal_and_support'
title={
<FormattedMessage
diff --git a/webapp/components/admin_console/custom_emoji_settings.jsx b/webapp/components/admin_console/custom_emoji_settings.jsx
new file mode 100644
index 000000000..332c7b216
--- /dev/null
+++ b/webapp/components/admin_console/custom_emoji_settings.jsx
@@ -0,0 +1,91 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as Utils from 'utils/utils.jsx';
+
+import AdminSettings from './admin_settings.jsx';
+import BooleanSetting from './boolean_setting.jsx';
+import DropdownSetting from './dropdown_setting.jsx';
+import {FormattedMessage} from 'react-intl';
+import SettingsGroup from './settings_group.jsx';
+
+export default class CustomEmojiSettings extends AdminSettings {
+ constructor(props) {
+ super(props);
+
+ this.getConfigFromState = this.getConfigFromState.bind(this);
+
+ this.renderSettings = this.renderSettings.bind(this);
+
+ this.state = Object.assign(this.state, {
+ enableCustomEmoji: props.config.ServiceSettings.EnableCustomEmoji,
+ restrictCustomEmojiCreation: props.config.ServiceSettings.RestrictCustomEmojiCreation
+ });
+ }
+
+ getConfigFromState(config) {
+ config.ServiceSettings.EnableCustomEmoji = this.state.enableCustomEmoji;
+ config.ServiceSettings.RestrictCustomEmojiCreation = this.state.restrictCustomEmojiCreation;
+
+ return config;
+ }
+
+ renderTitle() {
+ return (
+ <h3>
+ <FormattedMessage
+ id='admin.customization.customEmoji'
+ defaultMessage='Custom Emoji'
+ />
+ </h3>
+ );
+ }
+
+ renderSettings() {
+ return (
+ <SettingsGroup>
+ <BooleanSetting
+ id='enableCustomEmoji'
+ label={
+ <FormattedMessage
+ id='admin.customization.enableCustomEmojiTitle'
+ defaultMessage='Enable Custom Emoji:'
+ />
+ }
+ helpText={
+ <FormattedMessage
+ id='admin.customization.enableCustomEmojiDesc'
+ defaultMessage='Enable users to create custom emoji for use in chat messages.'
+ />
+ }
+ value={this.state.enableCustomEmoji}
+ onChange={this.handleChange}
+ />
+ <DropdownSetting
+ id='restrictCustomEmojiCreation'
+ values={[
+ {value: 'all', text: Utils.localizeMessage('admin.customization.restrictCustomEmojiCreationAll', 'Allow everyone to create custom emoji')},
+ {value: 'system_admin', text: Utils.localizeMessage('admin.customization.restrictCustomEmojiCreationSystemAdmin', 'Only allow system admins to create custom emoji')}
+ ]}
+ label={
+ <FormattedMessage
+ id='admin.customization.restrictCustomEmojiCreationTitle'
+ defaultMessage='Restrict Custom Emoji Creation:'
+ />
+ }
+ helpText={
+ <FormattedMessage
+ id='admin.customization.restrictCustomEmojiCreationDesc'
+ defaultMessage='Restrict the creation of custom emoji to certain users.'
+ />
+ }
+ value={this.state.restrictCustomEmojiCreation}
+ onChange={this.handleChange}
+ disabled={!this.state.enableCustomEmoji}
+ />
+ </SettingsGroup>
+ );
+ }
+} \ 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 <a href=\"https://about.mattermost.com/hpns-terms/\" target=\"_blank\">Terms of Service</a> and <a href=\"https://about.mattermost.com/hpns-privacy/\" target=\"_blank\">Privacy Policy</a>.",
"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';
@@ -462,6 +463,10 @@ function renderRootComponent() {
component={CustomBrandSettings}
/>
<Route
+ path='custom_emoji'
+ component={CustomEmojiSettings}
+ />
+ <Route
path='legal_and_support'
component={LegalAndSupportSettings}
/>