diff options
-rw-r--r-- | api4/api.go | 2 | ||||
-rw-r--r-- | api4/emoji.go | 44 | ||||
-rw-r--r-- | api4/emoji_test.go | 134 | ||||
-rw-r--r-- | app/emoji.go | 16 | ||||
-rw-r--r-- | model/client4.go | 21 | ||||
-rw-r--r-- | model/emoji_search.go | 34 | ||||
-rw-r--r-- | model/emoji_search_test.go | 19 | ||||
-rw-r--r-- | store/sqlstore/emoji_store.go | 29 | ||||
-rw-r--r-- | store/store.go | 1 | ||||
-rw-r--r-- | store/storetest/emoji_store.go | 68 | ||||
-rw-r--r-- | store/storetest/mocks/EmojiStore.go | 16 |
11 files changed, 381 insertions, 3 deletions
diff --git a/api4/api.go b/api4/api.go index dd5e56d35..90c9d0df7 100644 --- a/api4/api.go +++ b/api4/api.go @@ -184,7 +184,7 @@ func Init(a *app.App, root *mux.Router, full bool) *API { api.BaseRoutes.DataRetention = api.BaseRoutes.ApiRoot.PathPrefix("/data_retention").Subrouter() api.BaseRoutes.Emojis = api.BaseRoutes.ApiRoot.PathPrefix("/emoji").Subrouter() - api.BaseRoutes.Emoji = api.BaseRoutes.Emojis.PathPrefix("/{emoji_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.Emoji = api.BaseRoutes.ApiRoot.PathPrefix("/emoji/{emoji_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.ReactionByNameForPostForUser = api.BaseRoutes.PostForUser.PathPrefix("/reactions/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}").Subrouter() diff --git a/api4/emoji.go b/api4/emoji.go index 049e77d3c..30d59125b 100644 --- a/api4/emoji.go +++ b/api4/emoji.go @@ -11,9 +11,15 @@ import ( "github.com/mattermost/mattermost-server/model" ) +const ( + EMOJI_MAX_AUTOCOMPLETE_ITEMS = 100 +) + func (api *API) InitEmoji() { api.BaseRoutes.Emojis.Handle("", api.ApiSessionRequired(createEmoji)).Methods("POST") api.BaseRoutes.Emojis.Handle("", api.ApiSessionRequired(getEmojiList)).Methods("GET") + api.BaseRoutes.Emojis.Handle("/search", api.ApiSessionRequired(searchEmojis)).Methods("POST") + api.BaseRoutes.Emojis.Handle("/autocomplete", api.ApiSessionRequired(autocompleteEmojis)).Methods("GET") api.BaseRoutes.Emoji.Handle("", api.ApiSessionRequired(deleteEmoji)).Methods("DELETE") api.BaseRoutes.Emoji.Handle("", api.ApiSessionRequired(getEmoji)).Methods("GET") api.BaseRoutes.Emoji.Handle("/image", api.ApiSessionRequiredTrustRequester(getEmojiImage)).Methods("GET") @@ -162,3 +168,41 @@ func getEmojiImage(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=2592000, public") w.Write(image) } + +func searchEmojis(c *Context, w http.ResponseWriter, r *http.Request) { + emojiSearch := model.EmojiSearchFromJson(r.Body) + if emojiSearch == nil { + c.SetInvalidParam("term") + return + } + + if emojiSearch.Term == "" { + c.SetInvalidParam("term") + return + } + + emojis, err := c.App.SearchEmoji(emojiSearch.Term, emojiSearch.PrefixOnly, PER_PAGE_MAXIMUM) + if err != nil { + c.Err = err + return + } else { + w.Write([]byte(model.EmojiListToJson(emojis))) + } +} + +func autocompleteEmojis(c *Context, w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + + if name == "" { + c.SetInvalidUrlParam("name") + return + } + + emojis, err := c.App.SearchEmoji(name, true, EMOJI_MAX_AUTOCOMPLETE_ITEMS) + if err != nil { + c.Err = err + return + } else { + w.Write([]byte(model.EmojiListToJson(emojis))) + } +} diff --git a/api4/emoji_test.go b/api4/emoji_test.go index 3b0fecb2b..b8b093656 100644 --- a/api4/emoji_test.go +++ b/api4/emoji_test.go @@ -11,6 +11,8 @@ import ( "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" + + "github.com/stretchr/testify/assert" ) func TestCreateEmoji(t *testing.T) { @@ -432,3 +434,135 @@ func TestGetEmojiImage(t *testing.T) { _, resp = Client.GetEmojiImage("") CheckBadRequestStatus(t, resp) } + +func TestSearchEmoji(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true }) + + searchTerm1 := model.NewId() + searchTerm2 := model.NewId() + + emojis := []*model.Emoji{ + { + CreatorId: th.BasicUser.Id, + Name: searchTerm1, + }, + { + CreatorId: th.BasicUser.Id, + Name: "blargh_" + searchTerm2, + }, + } + + for idx, emoji := range emojis { + emoji, resp := Client.CreateEmoji(emoji, utils.CreateTestGif(t, 10, 10), "image.gif") + CheckNoError(t, resp) + emojis[idx] = emoji + } + + search := &model.EmojiSearch{Term: searchTerm1} + remojis, resp := Client.SearchEmoji(search) + CheckNoError(t, resp) + CheckOKStatus(t, resp) + + found := false + for _, e := range remojis { + if e.Name == emojis[0].Name { + found = true + } + } + + assert.True(t, found) + + search.Term = searchTerm2 + search.PrefixOnly = true + remojis, resp = Client.SearchEmoji(search) + CheckNoError(t, resp) + CheckOKStatus(t, resp) + + found = false + for _, e := range remojis { + if e.Name == emojis[1].Name { + found = true + } + } + + assert.False(t, found) + + search.PrefixOnly = false + remojis, resp = Client.SearchEmoji(search) + CheckNoError(t, resp) + CheckOKStatus(t, resp) + + found = false + for _, e := range remojis { + if e.Name == emojis[1].Name { + found = true + } + } + + assert.True(t, found) + + search.Term = "" + _, resp = Client.SearchEmoji(search) + CheckBadRequestStatus(t, resp) + + Client.Logout() + _, resp = Client.SearchEmoji(search) + CheckUnauthorizedStatus(t, resp) +} + +func TestAutocompleteEmoji(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true }) + + searchTerm1 := model.NewId() + + emojis := []*model.Emoji{ + { + CreatorId: th.BasicUser.Id, + Name: searchTerm1, + }, + { + CreatorId: th.BasicUser.Id, + Name: "blargh_" + searchTerm1, + }, + } + + for idx, emoji := range emojis { + emoji, resp := Client.CreateEmoji(emoji, utils.CreateTestGif(t, 10, 10), "image.gif") + CheckNoError(t, resp) + emojis[idx] = emoji + } + + remojis, resp := Client.AutocompleteEmoji(searchTerm1, "") + CheckNoError(t, resp) + CheckOKStatus(t, resp) + + found1 := false + found2 := false + for _, e := range remojis { + if e.Name == emojis[0].Name { + found1 = true + } + + if e.Name == emojis[1].Name { + found2 = true + } + } + + assert.True(t, found1) + assert.False(t, found2) + + _, resp = Client.AutocompleteEmoji("", "") + CheckBadRequestStatus(t, resp) + + Client.Logout() + _, resp = Client.AutocompleteEmoji(searchTerm1, "") + CheckUnauthorizedStatus(t, resp) +} diff --git a/app/emoji.go b/app/emoji.go index 2786af9c9..2271d650d 100644 --- a/app/emoji.go +++ b/app/emoji.go @@ -134,11 +134,11 @@ func (a *App) DeleteEmoji(emoji *model.Emoji) *model.AppError { func (a *App) GetEmoji(emojiId string) (*model.Emoji, *model.AppError) { if !*a.Config().ServiceSettings.EnableCustomEmoji { - return nil, model.NewAppError("deleteEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented) + return nil, model.NewAppError("GetEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented) } if len(*a.Config().FileSettings.DriverName) == 0 { - return nil, model.NewAppError("deleteImage", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented) + return nil, model.NewAppError("GetEmoji", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented) } if result := <-a.Srv.Store.Emoji().Get(emojiId, false); result.Err != nil { @@ -169,6 +169,18 @@ func (a *App) GetEmojiImage(emojiId string) (imageByte []byte, imageType string, } } +func (a *App) SearchEmoji(name string, prefixOnly bool, limit int) ([]*model.Emoji, *model.AppError) { + if !*a.Config().ServiceSettings.EnableCustomEmoji { + return nil, model.NewAppError("SearchEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + if result := <-a.Srv.Store.Emoji().Search(name, prefixOnly, limit); result.Err != nil { + return nil, result.Err + } else { + return result.Data.([]*model.Emoji), nil + } +} + func resizeEmojiGif(gifImg *gif.GIF) *gif.GIF { // Create a new RGBA image to hold the incremental frames. firstFrame := gifImg.Image[0].Bounds() diff --git a/model/client4.go b/model/client4.go index c44855993..151b5a491 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3070,6 +3070,27 @@ func (c *Client4) GetEmojiImage(emojiId string) ([]byte, *Response) { } } +// SearchEmoji returns a list of emoji matching some search criteria. +func (c *Client4) SearchEmoji(search *EmojiSearch) ([]*Emoji, *Response) { + if r, err := c.DoApiPost(c.GetEmojisRoute()+"/search", search.ToJson()); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return EmojiListFromJson(r.Body), BuildResponse(r) + } +} + +// AutocompleteEmoji returns a list of emoji starting with or matching name. +func (c *Client4) AutocompleteEmoji(name string, etag string) ([]*Emoji, *Response) { + query := fmt.Sprintf("?name=%v", name) + if r, err := c.DoApiGet(c.GetEmojisRoute()+"/autocomplete"+query, ""); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return EmojiListFromJson(r.Body), BuildResponse(r) + } +} + // Reaction Section // SaveReaction saves an emoji reaction for a post. Returns the saved reaction if successful, otherwise an error will be returned. diff --git a/model/emoji_search.go b/model/emoji_search.go new file mode 100644 index 000000000..31931170e --- /dev/null +++ b/model/emoji_search.go @@ -0,0 +1,34 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type EmojiSearch struct { + Term string `json:"term"` + PrefixOnly bool `json:"prefix_only"` +} + +func (es *EmojiSearch) ToJson() string { + b, err := json.Marshal(es) + if err != nil { + return "" + } else { + return string(b) + } +} + +func EmojiSearchFromJson(data io.Reader) *EmojiSearch { + decoder := json.NewDecoder(data) + var es EmojiSearch + err := decoder.Decode(&es) + if err == nil { + return &es + } else { + return nil + } +} diff --git a/model/emoji_search_test.go b/model/emoji_search_test.go new file mode 100644 index 000000000..6e3b01213 --- /dev/null +++ b/model/emoji_search_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestEmojiSearchJson(t *testing.T) { + emojiSearch := EmojiSearch{Term: NewId()} + json := emojiSearch.ToJson() + remojiSearch := EmojiSearchFromJson(strings.NewReader(json)) + + if emojiSearch.Term != remojiSearch.Term { + t.Fatal("Terms do not match") + } +} diff --git a/store/sqlstore/emoji_store.go b/store/sqlstore/emoji_store.go index 734190dbb..afd87b83d 100644 --- a/store/sqlstore/emoji_store.go +++ b/store/sqlstore/emoji_store.go @@ -46,6 +46,7 @@ func (es SqlEmojiStore) CreateIndexesIfNotExists() { es.CreateIndexIfNotExists("idx_emoji_update_at", "Emoji", "UpdateAt") es.CreateIndexIfNotExists("idx_emoji_create_at", "Emoji", "CreateAt") es.CreateIndexIfNotExists("idx_emoji_delete_at", "Emoji", "DeleteAt") + es.CreateIndexIfNotExists("idx_emoji_name", "Emoji", "Name") } func (es SqlEmojiStore) Save(emoji *model.Emoji) store.StoreChannel { @@ -162,3 +163,31 @@ func (es SqlEmojiStore) Delete(id string, time int64) store.StoreChannel { emojiCache.Remove(id) }) } + +func (es SqlEmojiStore) Search(name string, prefixOnly bool, limit int) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + var emojis []*model.Emoji + + term := "" + if !prefixOnly { + term = "%" + } + + term += name + "%" + + if _, err := es.GetReplica().Select(&emojis, + `SELECT + * + FROM + Emoji + WHERE + Name LIKE :Name + AND DeleteAt = 0 + ORDER BY Name + LIMIT :Limit`, map[string]interface{}{"Name": term, "Limit": limit}); err != nil { + result.Err = model.NewAppError("SqlEmojiStore.Search", "store.sql_emoji.get_by_name.app_error", nil, "name="+name+", "+err.Error(), http.StatusInternalServerError) + } else { + result.Data = emojis + } + }) +} diff --git a/store/store.go b/store/store.go index 8cb5093ea..2742c0889 100644 --- a/store/store.go +++ b/store/store.go @@ -393,6 +393,7 @@ type EmojiStore interface { GetByName(name string) StoreChannel GetList(offset, limit int, sort string) StoreChannel Delete(id string, time int64) StoreChannel + Search(name string, prefixOnly bool, limit int) StoreChannel } type StatusStore interface { diff --git a/store/storetest/emoji_store.go b/store/storetest/emoji_store.go index a862440e5..9e4dbaa6e 100644 --- a/store/storetest/emoji_store.go +++ b/store/storetest/emoji_store.go @@ -18,6 +18,7 @@ func TestEmojiStore(t *testing.T, ss store.Store) { t.Run("EmojiGet", func(t *testing.T) { testEmojiGet(t, ss) }) t.Run("EmojiGetByName", func(t *testing.T) { testEmojiGetByName(t, ss) }) t.Run("EmojiGetList", func(t *testing.T) { testEmojiGetList(t, ss) }) + t.Run("EmojiSearch", func(t *testing.T) { testEmojiSearch(t, ss) }) } func testEmojiSaveDelete(t *testing.T, ss store.Store) { @@ -191,3 +192,70 @@ func testEmojiGetList(t *testing.T, ss store.Store) { assert.Equal(t, emojis[2].Name, remojis[1].Name) } + +func testEmojiSearch(t *testing.T, ss store.Store) { + emojis := []model.Emoji{ + { + CreatorId: model.NewId(), + Name: "blargh_" + model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId() + "_blargh", + }, + { + CreatorId: model.NewId(), + Name: model.NewId() + "_blargh_" + model.NewId(), + }, + { + CreatorId: model.NewId(), + Name: model.NewId(), + }, + } + + for i, emoji := range emojis { + emojis[i] = *store.Must(ss.Emoji().Save(&emoji)).(*model.Emoji) + } + defer func() { + for _, emoji := range emojis { + store.Must(ss.Emoji().Delete(emoji.Id, time.Now().Unix())) + } + }() + + shouldFind := []bool{true, false, false, false} + + if result := <-ss.Emoji().Search("blargh", true, 100); result.Err != nil { + t.Fatal(result.Err) + } else { + for i, emoji := range emojis { + found := false + + for _, savedEmoji := range result.Data.([]*model.Emoji) { + if emoji.Id == savedEmoji.Id { + found = true + break + } + } + + assert.Equal(t, shouldFind[i], found, emoji.Name) + } + } + + shouldFind = []bool{true, true, true, false} + if result := <-ss.Emoji().Search("blargh", false, 100); result.Err != nil { + t.Fatal(result.Err) + } else { + for i, emoji := range emojis { + found := false + + for _, savedEmoji := range result.Data.([]*model.Emoji) { + if emoji.Id == savedEmoji.Id { + found = true + break + } + } + + assert.Equal(t, shouldFind[i], found, emoji.Name) + } + } +} diff --git a/store/storetest/mocks/EmojiStore.go b/store/storetest/mocks/EmojiStore.go index d1bfe7f00..9871c98aa 100644 --- a/store/storetest/mocks/EmojiStore.go +++ b/store/storetest/mocks/EmojiStore.go @@ -92,3 +92,19 @@ func (_m *EmojiStore) Save(emoji *model.Emoji) store.StoreChannel { return r0 } + +// Search provides a mock function with given fields: name, prefixOnly, limit +func (_m *EmojiStore) Search(name string, prefixOnly bool, limit int) store.StoreChannel { + ret := _m.Called(name, prefixOnly, limit) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, bool, int) store.StoreChannel); ok { + r0 = rf(name, prefixOnly, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} |