From 4cd00d546c495b085487d11f2fe2c4928600dc10 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 14 Nov 2022 23:47:27 +0100 Subject: [PATCH] [feature] Allow newly uploaded emojis to be placed in categories (#939) * [feature] Add emoji categories GET Serialize emojis in appropriate categories; make it possible to get categories via the admin API * [feature] Create (or use existing) category for new emoji uploads * fix lint issue * update misleading line in swagger docs --- docs/api/swagger.yaml | 56 +++++++ internal/api/client/admin/admin.go | 3 + internal/api/client/admin/admin_test.go | 18 ++- .../api/client/admin/emojicategoriesget.go | 94 +++++++++++ .../client/admin/emojicategoriesget_test.go | 53 +++++++ internal/api/client/admin/emojicreate.go | 15 +- internal/api/client/admin/emojicreate_test.go | 149 +++++++++++++++++- internal/api/client/admin/emojidelete_test.go | 2 +- internal/api/client/admin/emojiget_test.go | 2 +- internal/api/model/emoji.go | 3 + internal/api/model/emojicategory.go | 29 ++++ internal/cache/emojicategory.go | 84 ++++++++++ internal/db/bundb/bundb.go | 2 +- internal/db/bundb/emoji.go | 119 ++++++++++++-- internal/db/bundb/emoji_test.go | 18 +++ .../20221031145649_emoji_categories.go | 46 ++++++ internal/db/emoji.go | 8 + internal/gtsmodel/emoji.go | 41 ++--- internal/gtsmodel/emojicategory.go | 29 ++++ internal/processing/admin.go | 4 + internal/processing/admin/admin.go | 1 + internal/processing/admin/createemoji.go | 15 +- internal/processing/admin/emojicategory.go | 60 +++++++ internal/processing/admin/getcategories.go | 47 ++++++ internal/processing/processor.go | 2 + internal/typeutils/converter.go | 2 + internal/typeutils/internaltofrontend.go | 21 ++- internal/typeutils/internaltofrontend_test.go | 10 +- internal/validate/formvalidation.go | 9 ++ testrig/db.go | 7 + testrig/testmodels.go | 19 ++- 31 files changed, 916 insertions(+), 52 deletions(-) create mode 100644 internal/api/client/admin/emojicategoriesget.go create mode 100644 internal/api/client/admin/emojicategoriesget_test.go create mode 100644 internal/api/model/emojicategory.go create mode 100644 internal/cache/emojicategory.go create mode 100644 internal/db/bundb/migrations/20221031145649_emoji_categories.go create mode 100644 internal/gtsmodel/emojicategory.go create mode 100644 internal/processing/admin/emojicategory.go create mode 100644 internal/processing/admin/getcategories.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 9eb10269b..528ea03c7 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -859,8 +859,27 @@ definitions: type: object x-go-name: Emoji x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + emojiCategory: + properties: + id: + description: The ID of the custom emoji category. + type: string + x-go-name: ID + name: + description: The name of the custom emoji category. + type: string + x-go-name: Name + title: EmojiCategory represents a custom emoji category. + type: object + x-go-name: EmojiCategory + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model emojiCreateRequest: properties: + CategoryName: + description: |- + Category in which to place the new emoji. Will be uncategorized by default. + CategoryName length should not exceed 64 characters. + type: string Image: description: Image file to use for the emoji. Must be png or gif and no larger than 50kb. Shortcode: @@ -2755,6 +2774,10 @@ paths: name: image required: true type: file + - description: Category in which to place the new emoji. 64 characters or less. If left blank, emoji will be uncategorized. If a category with the given name doesn't exist yet, it will be created. + in: formData + name: category + type: string produces: - application/json responses: @@ -2852,6 +2875,39 @@ paths: summary: Get the admin view of a single emoji. tags: - admin + /api/v1/admin/custom_emojis/categories: + get: + operationId: emojiCategoriesGet + parameters: + - description: The id of the emoji. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Array of existing emoji categories. + schema: + items: + $ref: '#/definitions/adminEmojiCategory' + type: array + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + summary: Get a list of existing emoji categories. + tags: + - admin /api/v1/admin/domain_blocks: get: operationId: domainBlocksGet diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 0ef8b4fcc..e34bac1cf 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -33,6 +33,8 @@ EmojiPath = BasePath + "/custom_emojis" // EmojiPathWithID is used for interacting with a single emoji. EmojiPathWithID = EmojiPath + "/:" + IDKey + // EmojiCategoriesPath is used for interacting with emoji categories. + EmojiCategoriesPath = EmojiPath + "/categories" // DomainBlocksPath is used for posting domain blocks. DomainBlocksPath = BasePath + "/domain_blocks" // DomainBlocksPathWithID is used for interacting with a single domain block. @@ -87,5 +89,6 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) r.AttachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) r.AttachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler) + r.AttachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler) return nil } diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go index ce026a67b..9303ee3f2 100644 --- a/internal/api/client/admin/admin_test.go +++ b/internal/api/client/admin/admin_test.go @@ -53,14 +53,15 @@ type AdminStandardTestSuite struct { sentEmails map[string]string // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testEmojis map[string]*gtsmodel.Emoji + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testEmojis map[string]*gtsmodel.Emoji + testEmojiCategories map[string]*gtsmodel.EmojiCategory // module being tested adminModule *admin.Module @@ -75,6 +76,7 @@ func (suite *AdminStandardTestSuite) SetupSuite() { suite.testAttachments = testrig.NewTestAttachments() suite.testStatuses = testrig.NewTestStatuses() suite.testEmojis = testrig.NewTestEmojis() + suite.testEmojiCategories = testrig.NewTestEmojiCategories() } func (suite *AdminStandardTestSuite) SetupTest() { diff --git a/internal/api/client/admin/emojicategoriesget.go b/internal/api/client/admin/emojicategoriesget.go new file mode 100644 index 000000000..d8b379674 --- /dev/null +++ b/internal/api/client/admin/emojicategoriesget.go @@ -0,0 +1,94 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// EmojiCategoriesGETHandler swagger:operation GET /api/v1/admin/custom_emojis/categories emojiCategoriesGet +// +// Get a list of existing emoji categories. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the emoji. +// in: path +// required: true +// +// responses: +// '200': +// description: Array of existing emoji categories. +// schema: +// type: array +// items: +// "$ref": "#/definitions/adminEmojiCategory" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) EmojiCategoriesGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + categories, errWithCode := m.processor.AdminEmojiCategoriesGet(c.Request.Context()) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, categories) +} diff --git a/internal/api/client/admin/emojicategoriesget_test.go b/internal/api/client/admin/emojicategoriesget_test.go new file mode 100644 index 000000000..ac6b73931 --- /dev/null +++ b/internal/api/client/admin/emojicategoriesget_test.go @@ -0,0 +1,53 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package admin_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +) + +type EmojiCategoriesGetTestSuite struct { + AdminStandardTestSuite +} + +func (suite *EmojiCategoriesGetTestSuite) TestEmojiCategoriesGet() { + recorder := httptest.NewRecorder() + + path := admin.EmojiCategoriesPath + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.EmojiCategoriesGETHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + + suite.Equal(`[{"id":"01GGQ989PTT9PMRN4FZ1WWK2B9","name":"cute stuff"},{"id":"01GGQ8V4993XK67B2JB396YFB7","name":"reactions"}]`, string(b)) +} + +func TestEmojiCategoriesGetTestSuite(t *testing.T) { + suite.Run(t, &EmojiCategoriesGetTestSuite{}) +} diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go index b8dbfe43e..2a075708f 100644 --- a/internal/api/client/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -64,6 +64,15 @@ // To ensure compatibility with other fedi implementations, emoji size limit is 50kb by default. // type: file // required: true +// - +// name: category +// in: formData +// description: >- +// Category in which to place the new emoji. 64 characters or less. +// If left blank, emoji will be uncategorized. If a category with the +// given name doesn't exist yet, it will be created. +// type: string +// required: false // // security: // - OAuth2 Bearer: @@ -136,5 +145,9 @@ func validateCreateEmoji(form *model.EmojiCreateRequest) error { return fmt.Errorf("emoji image too large: image is %dKB but size limit for custom emojis is %dKB", form.Image.Size/1024, maxSize/1024) } - return validate.EmojiShortcode(form.Shortcode) + if err := validate.EmojiShortcode(form.Shortcode); err != nil { + return err + } + + return validate.EmojiCategory(form.CategoryName) } diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go index 3131e0816..9078fe16e 100644 --- a/internal/api/client/admin/emojicreate_test.go +++ b/internal/api/client/admin/emojicreate_test.go @@ -36,12 +36,159 @@ type EmojiCreateTestSuite struct { AdminStandardTestSuite } -func (suite *EmojiCreateTestSuite) TestEmojiCreate() { +func (suite *EmojiCreateTestSuite) TestEmojiCreateNewCategory() { // set up the request requestBody, w, err := testrig.CreateMultipartFormData( "image", "../../../../testrig/media/rainbow-original.png", map[string]string{ "shortcode": "new_emoji", + "category": "Test Emojis", // this category doesn't exist yet + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPath, w.FormDataContentType()) + + // call the handler + suite.adminModule.EmojiCreatePOSTHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.NotEmpty(b) + + // response should be an api model emoji + apiEmoji := &apimodel.Emoji{} + err = json.Unmarshal(b, apiEmoji) + suite.NoError(err) + + // appropriate fields should be set + suite.Equal("new_emoji", apiEmoji.Shortcode) + suite.NotEmpty(apiEmoji.URL) + suite.NotEmpty(apiEmoji.StaticURL) + suite.True(apiEmoji.VisibleInPicker) + + // emoji should be in the db + dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), apiEmoji.Shortcode, "") + suite.NoError(err) + + // check fields on the emoji + suite.NotEmpty(dbEmoji.ID) + suite.Equal("new_emoji", dbEmoji.Shortcode) + suite.Empty(dbEmoji.Domain) + suite.Empty(dbEmoji.ImageRemoteURL) + suite.Empty(dbEmoji.ImageStaticRemoteURL) + suite.Equal(apiEmoji.URL, dbEmoji.ImageURL) + suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL) + suite.NotEmpty(dbEmoji.ImagePath) + suite.NotEmpty(dbEmoji.ImageStaticPath) + suite.Equal("image/png", dbEmoji.ImageContentType) + suite.Equal("image/png", dbEmoji.ImageStaticContentType) + suite.Equal(36702, dbEmoji.ImageFileSize) + suite.Equal(10413, dbEmoji.ImageStaticFileSize) + suite.False(*dbEmoji.Disabled) + suite.NotEmpty(dbEmoji.URI) + suite.True(*dbEmoji.VisibleInPicker) + suite.NotEmpty(dbEmoji.CategoryID) + + // emoji should be in storage + emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath) + suite.NoError(err) + suite.Len(emojiBytes, dbEmoji.ImageFileSize) + emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath) + suite.NoError(err) + suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiCreateTestSuite) TestEmojiCreateExistingCategory() { + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "image", "../../../../testrig/media/rainbow-original.png", + map[string]string{ + "shortcode": "new_emoji", + "category": "cute stuff", // this category already exists + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPath, w.FormDataContentType()) + + // call the handler + suite.adminModule.EmojiCreatePOSTHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.NotEmpty(b) + + // response should be an api model emoji + apiEmoji := &apimodel.Emoji{} + err = json.Unmarshal(b, apiEmoji) + suite.NoError(err) + + // appropriate fields should be set + suite.Equal("new_emoji", apiEmoji.Shortcode) + suite.NotEmpty(apiEmoji.URL) + suite.NotEmpty(apiEmoji.StaticURL) + suite.True(apiEmoji.VisibleInPicker) + + // emoji should be in the db + dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), apiEmoji.Shortcode, "") + suite.NoError(err) + + // check fields on the emoji + suite.NotEmpty(dbEmoji.ID) + suite.Equal("new_emoji", dbEmoji.Shortcode) + suite.Empty(dbEmoji.Domain) + suite.Empty(dbEmoji.ImageRemoteURL) + suite.Empty(dbEmoji.ImageStaticRemoteURL) + suite.Equal(apiEmoji.URL, dbEmoji.ImageURL) + suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL) + suite.NotEmpty(dbEmoji.ImagePath) + suite.NotEmpty(dbEmoji.ImageStaticPath) + suite.Equal("image/png", dbEmoji.ImageContentType) + suite.Equal("image/png", dbEmoji.ImageStaticContentType) + suite.Equal(36702, dbEmoji.ImageFileSize) + suite.Equal(10413, dbEmoji.ImageStaticFileSize) + suite.False(*dbEmoji.Disabled) + suite.NotEmpty(dbEmoji.URI) + suite.True(*dbEmoji.VisibleInPicker) + suite.Equal(suite.testEmojiCategories["cute stuff"].ID, dbEmoji.CategoryID) + + // emoji should be in storage + emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath) + suite.NoError(err) + suite.Len(emojiBytes, dbEmoji.ImageFileSize) + emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath) + suite.NoError(err) + suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiCreateTestSuite) TestEmojiCreateNoCategory() { + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "image", "../../../../testrig/media/rainbow-original.png", + map[string]string{ + "shortcode": "new_emoji", + "category": "", }) if err != nil { panic(err) diff --git a/internal/api/client/admin/emojidelete_test.go b/internal/api/client/admin/emojidelete_test.go index c930c377a..350eb1159 100644 --- a/internal/api/client/admin/emojidelete_test.go +++ b/internal/api/client/admin/emojidelete_test.go @@ -49,7 +49,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() { suite.NoError(err) suite.NotNil(b) - suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) + suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions","id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) // emoji should no longer be in the db dbEmoji, err := suite.db.GetEmojiByID(context.Background(), testEmoji.ID) diff --git a/internal/api/client/admin/emojiget_test.go b/internal/api/client/admin/emojiget_test.go index d94e2bf78..6e1882c70 100644 --- a/internal/api/client/admin/emojiget_test.go +++ b/internal/api/client/admin/emojiget_test.go @@ -47,7 +47,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() { suite.NoError(err) suite.NotNil(b) - suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) + suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions","id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) } func (suite *EmojiGetTestSuite) TestEmojiGet2() { diff --git a/internal/api/model/emoji.go b/internal/api/model/emoji.go index 2fa858596..eb636f63c 100644 --- a/internal/api/model/emoji.go +++ b/internal/api/model/emoji.go @@ -50,4 +50,7 @@ type EmojiCreateRequest struct { Shortcode string `form:"shortcode" validation:"required"` // Image file to use for the emoji. Must be png or gif and no larger than 50kb. Image *multipart.FileHeader `form:"image" validation:"required"` + // Category in which to place the new emoji. Will be uncategorized by default. + // CategoryName length should not exceed 64 characters. + CategoryName string `form:"category"` } diff --git a/internal/api/model/emojicategory.go b/internal/api/model/emojicategory.go new file mode 100644 index 000000000..0a14b303e --- /dev/null +++ b/internal/api/model/emojicategory.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package model + +// EmojiCategory represents a custom emoji category. +// +// swagger:model emojiCategory +type EmojiCategory struct { + // The ID of the custom emoji category. + ID string `json:"id"` + // The name of the custom emoji category. + Name string `json:"name"` +} diff --git a/internal/cache/emojicategory.go b/internal/cache/emojicategory.go new file mode 100644 index 000000000..17df5591a --- /dev/null +++ b/internal/cache/emojicategory.go @@ -0,0 +1,84 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package cache + +import ( + "strings" + "time" + + "codeberg.org/gruf/go-cache/v2" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// EmojiCategoryCache is a cache wrapper to provide ID lookups for gtsmodel.EmojiCategory +type EmojiCategoryCache struct { + cache cache.LookupCache[string, string, *gtsmodel.EmojiCategory] +} + +// NewEmojiCategoryCache returns a new instantiated EmojiCategoryCache object +func NewEmojiCategoryCache() *EmojiCategoryCache { + c := &EmojiCategoryCache{} + c.cache = cache.NewLookup(cache.LookupCfg[string, string, *gtsmodel.EmojiCategory]{ + RegisterLookups: func(lm *cache.LookupMap[string, string]) { + lm.RegisterLookup("name") + }, + + AddLookups: func(lm *cache.LookupMap[string, string], emojiCategory *gtsmodel.EmojiCategory) { + lm.Set(("name"), strings.ToLower(emojiCategory.Name), emojiCategory.ID) + }, + + DeleteLookups: func(lm *cache.LookupMap[string, string], emojiCategory *gtsmodel.EmojiCategory) { + lm.Delete("name", strings.ToLower(emojiCategory.Name)) + }, + }) + c.cache.SetTTL(time.Minute*5, false) + c.cache.Start(time.Second * 10) + return c +} + +// GetByID attempts to fetch an emojiCategory from the cache by its ID, you will receive a copy for thread-safety +func (c *EmojiCategoryCache) GetByID(id string) (*gtsmodel.EmojiCategory, bool) { + return c.cache.Get(id) +} + +// GetByName attempts to fetch an emojiCategory from the cache by its name, you will receive a copy for thread-safety +func (c *EmojiCategoryCache) GetByName(name string) (*gtsmodel.EmojiCategory, bool) { + return c.cache.GetBy("name", strings.ToLower(name)) +} + +// Put places an emojiCategory in the cache, ensuring that the object place is a copy for thread-safety +func (c *EmojiCategoryCache) Put(emoji *gtsmodel.EmojiCategory) { + if emoji == nil || emoji.ID == "" { + panic("invalid emoji") + } + c.cache.Set(emoji.ID, copyEmojiCategory(emoji)) +} + +func (c *EmojiCategoryCache) Invalidate(emojiID string) { + c.cache.Invalidate(emojiID) +} + +func copyEmojiCategory(emojiCategory *gtsmodel.EmojiCategory) *gtsmodel.EmojiCategory { + return >smodel.EmojiCategory{ + ID: emojiCategory.ID, + CreatedAt: emojiCategory.CreatedAt, + UpdatedAt: emojiCategory.UpdatedAt, + Name: emojiCategory.Name, + } +} diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index 43e9a07c9..cf6643f6b 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -180,7 +180,7 @@ func NewBunDBService(ctx context.Context) (db.DB, error) { // Create DB structs that require ptrs to each other accounts := &accountDB{conn: conn, cache: accountCache} status := &statusDB{conn: conn, cache: cache.NewStatusCache()} - emoji := &emojiDB{conn: conn, cache: cache.NewEmojiCache()} + emoji := &emojiDB{conn: conn, emojiCache: cache.NewEmojiCache(), categoryCache: cache.NewEmojiCategoryCache()} timeline := &timelineDB{conn: conn} tombstone := &tombstoneDB{conn: conn} diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go index 51d767a7b..81374ce78 100644 --- a/internal/db/bundb/emoji.go +++ b/internal/db/bundb/emoji.go @@ -32,14 +32,22 @@ ) type emojiDB struct { - conn *DBConn - cache *cache.EmojiCache + conn *DBConn + emojiCache *cache.EmojiCache + categoryCache *cache.EmojiCategoryCache } func (e *emojiDB) newEmojiQ(emoji *gtsmodel.Emoji) *bun.SelectQuery { return e.conn. NewSelect(). - Model(emoji) + Model(emoji). + Relation("Category") +} + +func (e *emojiDB) newEmojiCategoryQ(emojiCategory *gtsmodel.EmojiCategory) *bun.SelectQuery { + return e.conn. + NewSelect(). + Model(emojiCategory) } func (e *emojiDB) PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) db.Error { @@ -47,7 +55,7 @@ func (e *emojiDB) PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) db.Error return e.conn.ProcessError(err) } - e.cache.Put(emoji) + e.emojiCache.Put(emoji) return nil } @@ -64,7 +72,7 @@ func (e *emojiDB) UpdateEmoji(ctx context.Context, emoji *gtsmodel.Emoji, column return nil, e.conn.ProcessError(err) } - e.cache.Invalidate(emoji.ID) + e.emojiCache.Invalidate(emoji.ID) return emoji, nil } @@ -101,7 +109,7 @@ func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) db.Error { return err } - e.cache.Invalidate(id) + e.emojiCache.Invalidate(id) return nil } @@ -245,7 +253,7 @@ func (e *emojiDB) GetEmojiByID(ctx context.Context, id string) (*gtsmodel.Emoji, return e.getEmoji( ctx, func() (*gtsmodel.Emoji, bool) { - return e.cache.GetByID(id) + return e.emojiCache.GetByID(id) }, func(emoji *gtsmodel.Emoji) error { return e.newEmojiQ(emoji).Where("? = ?", bun.Ident("emoji.id"), id).Scan(ctx) @@ -257,7 +265,7 @@ func (e *emojiDB) GetEmojiByURI(ctx context.Context, uri string) (*gtsmodel.Emoj return e.getEmoji( ctx, func() (*gtsmodel.Emoji, bool) { - return e.cache.GetByURI(uri) + return e.emojiCache.GetByURI(uri) }, func(emoji *gtsmodel.Emoji) error { return e.newEmojiQ(emoji).Where("? = ?", bun.Ident("emoji.uri"), uri).Scan(ctx) @@ -269,7 +277,7 @@ func (e *emojiDB) GetEmojiByShortcodeDomain(ctx context.Context, shortcode strin return e.getEmoji( ctx, func() (*gtsmodel.Emoji, bool) { - return e.cache.GetByShortcodeDomain(shortcode, domain) + return e.emojiCache.GetByShortcodeDomain(shortcode, domain) }, func(emoji *gtsmodel.Emoji) error { q := e.newEmojiQ(emoji) @@ -291,7 +299,7 @@ func (e *emojiDB) GetEmojiByStaticURL(ctx context.Context, imageStaticURL string return e.getEmoji( ctx, func() (*gtsmodel.Emoji, bool) { - return e.cache.GetByImageStaticURL(imageStaticURL) + return e.emojiCache.GetByImageStaticURL(imageStaticURL) }, func(emoji *gtsmodel.Emoji) error { return e. @@ -302,6 +310,55 @@ func(emoji *gtsmodel.Emoji) error { ) } +func (e *emojiDB) PutEmojiCategory(ctx context.Context, emojiCategory *gtsmodel.EmojiCategory) db.Error { + if _, err := e.conn.NewInsert().Model(emojiCategory).Exec(ctx); err != nil { + return e.conn.ProcessError(err) + } + + e.categoryCache.Put(emojiCategory) + return nil +} + +func (e *emojiDB) GetEmojiCategories(ctx context.Context) ([]*gtsmodel.EmojiCategory, db.Error) { + emojiCategoryIDs := []string{} + + q := e.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("emoji_categories"), bun.Ident("emoji_category")). + Column("emoji_category.id"). + Order("emoji_category.name ASC") + + if err := q.Scan(ctx, &emojiCategoryIDs); err != nil { + return nil, e.conn.ProcessError(err) + } + + return e.emojiCategoriesFromIDs(ctx, emojiCategoryIDs) +} + +func (e *emojiDB) GetEmojiCategory(ctx context.Context, id string) (*gtsmodel.EmojiCategory, db.Error) { + return e.getEmojiCategory( + ctx, + func() (*gtsmodel.EmojiCategory, bool) { + return e.categoryCache.GetByID(id) + }, + func(emojiCategory *gtsmodel.EmojiCategory) error { + return e.newEmojiCategoryQ(emojiCategory).Where("? = ?", bun.Ident("emoji_category.id"), id).Scan(ctx) + }, + ) +} + +func (e *emojiDB) GetEmojiCategoryByName(ctx context.Context, name string) (*gtsmodel.EmojiCategory, db.Error) { + return e.getEmojiCategory( + ctx, + func() (*gtsmodel.EmojiCategory, bool) { + return e.categoryCache.GetByName(name) + }, + func(emojiCategory *gtsmodel.EmojiCategory) error { + return e.newEmojiCategoryQ(emojiCategory).Where("LOWER(?) = ?", bun.Ident("emoji_category.name"), strings.ToLower(name)).Scan(ctx) + }, + ) +} + func (e *emojiDB) getEmoji(ctx context.Context, cacheGet func() (*gtsmodel.Emoji, bool), dbQuery func(*gtsmodel.Emoji) error) (*gtsmodel.Emoji, db.Error) { // Attempt to fetch cached emoji emoji, cached := cacheGet() @@ -316,7 +373,7 @@ func (e *emojiDB) getEmoji(ctx context.Context, cacheGet func() (*gtsmodel.Emoji } // Place in the cache - e.cache.Put(emoji) + e.emojiCache.Put(emoji) } return emoji, nil @@ -341,3 +398,43 @@ func (e *emojiDB) emojisFromIDs(ctx context.Context, emojiIDs []string) ([]*gtsm return emojis, nil } + +func (e *emojiDB) getEmojiCategory(ctx context.Context, cacheGet func() (*gtsmodel.EmojiCategory, bool), dbQuery func(*gtsmodel.EmojiCategory) error) (*gtsmodel.EmojiCategory, db.Error) { + // Attempt to fetch cached emoji categories + emojiCategory, cached := cacheGet() + + if !cached { + emojiCategory = >smodel.EmojiCategory{} + + // Not cached! Perform database query + err := dbQuery(emojiCategory) + if err != nil { + return nil, e.conn.ProcessError(err) + } + + // Place in the cache + e.categoryCache.Put(emojiCategory) + } + + return emojiCategory, nil +} + +func (e *emojiDB) emojiCategoriesFromIDs(ctx context.Context, emojiCategoryIDs []string) ([]*gtsmodel.EmojiCategory, db.Error) { + // Catch case of no emoji categories early + if len(emojiCategoryIDs) == 0 { + return nil, db.ErrNoEntries + } + + emojiCategories := make([]*gtsmodel.EmojiCategory, 0, len(emojiCategoryIDs)) + + for _, id := range emojiCategoryIDs { + emojiCategory, err := e.GetEmojiCategory(ctx, id) + if err != nil { + log.Errorf("emojiCategoriesFromIDs: error getting emoji category %q: %v", id, err) + } + + emojiCategories = append(emojiCategories, emojiCategory) + } + + return emojiCategories, nil +} diff --git a/internal/db/bundb/emoji_test.go b/internal/db/bundb/emoji_test.go index b542f9b67..786d41e5d 100644 --- a/internal/db/bundb/emoji_test.go +++ b/internal/db/bundb/emoji_test.go @@ -24,6 +24,7 @@ "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/testrig" ) type EmojiTestSuite struct { @@ -54,6 +55,8 @@ func (suite *EmojiTestSuite) TestGetEmojiByStaticURL() { suite.NoError(err) suite.NotNil(emoji) suite.Equal("rainbow", emoji.Shortcode) + suite.NotNil(emoji.Category) + suite.Equal("reactions", emoji.Category.Name) } func (suite *EmojiTestSuite) TestGetAllEmojis() { @@ -143,6 +146,21 @@ func (suite *EmojiTestSuite) TestGetSpecificEmojisFromDomain2() { suite.Equal("yell", emojis[0].Shortcode) } +func (suite *EmojiTestSuite) TestGetEmojiCategories() { + categories, err := suite.db.GetEmojiCategories(context.Background()) + suite.NoError(err) + suite.Len(categories, 2) + // check alphabetical order + suite.Equal(categories[0].Name, "cute stuff") + suite.Equal(categories[1].Name, "reactions") +} + +func (suite *EmojiTestSuite) TestGetEmojiCategory() { + category, err := suite.db.GetEmojiCategory(context.Background(), testrig.NewTestEmojiCategories()["reactions"].ID) + suite.NoError(err) + suite.NotNil(category) +} + func TestEmojiTestSuite(t *testing.T) { suite.Run(t, new(EmojiTestSuite)) } diff --git a/internal/db/bundb/migrations/20221031145649_emoji_categories.go b/internal/db/bundb/migrations/20221031145649_emoji_categories.go new file mode 100644 index 000000000..02e4a1f3a --- /dev/null +++ b/internal/db/bundb/migrations/20221031145649_emoji_categories.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + if _, err := db.NewCreateTable().Model(>smodel.EmojiCategory{}).IfNotExists().Exec(ctx); err != nil { + return err + } + + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/emoji.go b/internal/db/emoji.go index d2f66a377..267213b2d 100644 --- a/internal/db/emoji.go +++ b/internal/db/emoji.go @@ -50,4 +50,12 @@ type Emoji interface { GetEmojiByURI(ctx context.Context, uri string) (*gtsmodel.Emoji, Error) // GetEmojiByStaticURL gets an emoji using the URL of the static version of the emoji image. GetEmojiByStaticURL(ctx context.Context, imageStaticURL string) (*gtsmodel.Emoji, Error) + // PutEmojiCategory puts one new emoji category in the database. + PutEmojiCategory(ctx context.Context, emojiCategory *gtsmodel.EmojiCategory) Error + // GetEmojiCategories gets a slice of the names of all existing emoji categories. + GetEmojiCategories(ctx context.Context) ([]*gtsmodel.EmojiCategory, Error) + // GetEmojiCategory gets one emoji category by its id. + GetEmojiCategory(ctx context.Context, id string) (*gtsmodel.EmojiCategory, Error) + // GetEmojiCategoryByName gets one emoji category by its name. + GetEmojiCategoryByName(ctx context.Context, name string) (*gtsmodel.EmojiCategory, Error) } diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go index 624ae491c..d7abb8c1c 100644 --- a/internal/gtsmodel/emoji.go +++ b/internal/gtsmodel/emoji.go @@ -22,24 +22,25 @@ // Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance. type Emoji struct { - ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated - Shortcode string `validate:"required" bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ eg., 'blob_hug' 'purple_heart' Must be unique with domain. - Domain string `validate:"omitempty,fqdn" bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. - ImageRemoteURL string `validate:"required_without=ImageURL,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis. - ImageStaticRemoteURL string `validate:"required_without=ImageStaticURL,omitempty,url" bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. - ImageURL string `validate:"required_without=ImageRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis. - ImageStaticURL string `validate:"required_without=ImageStaticRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. - ImagePath string `validate:"required,file" bun:",nullzero,notnull"` // Path of the emoji image in the server storage system. - ImageStaticPath string `validate:"required,file" bun:",nullzero,notnull"` // Path of a static version of the emoji image in the server storage system - ImageContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the emoji image - ImageStaticContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the static version of the emoji image. - ImageFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the emoji image file in bytes, for serving purposes. - ImageStaticFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. - ImageUpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the emoji image last updated? - Disabled *bool `validate:"-" bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown? - URI string `validate:"url" bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234' - VisibleInPicker *bool `validate:"-" bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker? - CategoryID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // In which emoji category is this emoji visible? + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Shortcode string `validate:"required" bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ eg., 'blob_hug' 'purple_heart' Must be unique with domain. + Domain string `validate:"omitempty,fqdn" bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. + ImageRemoteURL string `validate:"required_without=ImageURL,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis. + ImageStaticRemoteURL string `validate:"required_without=ImageStaticURL,omitempty,url" bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. + ImageURL string `validate:"required_without=ImageRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis. + ImageStaticURL string `validate:"required_without=ImageStaticRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. + ImagePath string `validate:"required,file" bun:",nullzero,notnull"` // Path of the emoji image in the server storage system. + ImageStaticPath string `validate:"required,file" bun:",nullzero,notnull"` // Path of a static version of the emoji image in the server storage system + ImageContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the emoji image + ImageStaticContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the static version of the emoji image. + ImageFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the emoji image file in bytes, for serving purposes. + ImageStaticFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. + ImageUpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the emoji image last updated? + Disabled *bool `validate:"-" bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown? + URI string `validate:"url" bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234' + VisibleInPicker *bool `validate:"-" bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker? + Category *EmojiCategory `validate:"-" bun:"rel:belongs-to"` // In which emoji category is this emoji visible? + CategoryID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to. } diff --git a/internal/gtsmodel/emojicategory.go b/internal/gtsmodel/emojicategory.go new file mode 100644 index 000000000..183e72dd0 --- /dev/null +++ b/internal/gtsmodel/emojicategory.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel + +import "time" + +// EmojiCategory represents a grouping of custom emojis. +type EmojiCategory struct { + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Name string `validate:"required" bun:",nullzero,notnull,unique"` // name of this category +} diff --git a/internal/processing/admin.go b/internal/processing/admin.go index 38ed0905f..f10e9d64a 100644 --- a/internal/processing/admin.go +++ b/internal/processing/admin.go @@ -46,6 +46,10 @@ func (p *processor) AdminEmojiDelete(ctx context.Context, authed *oauth.Auth, id return p.adminProcessor.EmojiDelete(ctx, id) } +func (p *processor) AdminEmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode) { + return p.adminProcessor.EmojiCategoriesGet(ctx) +} + func (p *processor) AdminDomainBlockCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) { return p.adminProcessor.DomainBlockCreate(ctx, authed.Account, form.Domain, form.Obfuscate, form.PublicComment, form.PrivateComment, "") } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 962a3ac7c..0e8f0c27a 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -44,6 +44,7 @@ type Processor interface { EmojisGet(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) EmojiGet(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, id string) (*apimodel.AdminEmoji, gtserror.WithCode) EmojiDelete(ctx context.Context, id string) (*apimodel.AdminEmoji, gtserror.WithCode) + EmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode } diff --git a/internal/processing/admin/createemoji.go b/internal/processing/admin/createemoji.go index a315e144e..db51d52b6 100644 --- a/internal/processing/admin/createemoji.go +++ b/internal/processing/admin/createemoji.go @@ -28,6 +28,7 @@ "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -57,7 +58,19 @@ func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, return f, form.Image.Size, err } - processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, nil, form.Shortcode, emojiID, emojiURI, nil, false) + var ai *media.AdditionalEmojiInfo + if form.CategoryName != "" { + category, err := p.GetOrCreateEmojiCategory(ctx, form.CategoryName) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting id in category: %s", err), "error putting id in category") + } + + ai = &media.AdditionalEmojiInfo{ + CategoryID: &category.ID, + } + } + + processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, nil, form.Shortcode, emojiID, emojiURI, ai, false) if err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("error processing emoji: %s", err), "error processing emoji") } diff --git a/internal/processing/admin/emojicategory.go b/internal/processing/admin/emojicategory.go new file mode 100644 index 000000000..c40649c62 --- /dev/null +++ b/internal/processing/admin/emojicategory.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package admin + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +func (p *processor) GetOrCreateEmojiCategory(ctx context.Context, name string) (*gtsmodel.EmojiCategory, error) { + category, err := p.db.GetEmojiCategoryByName(ctx, name) + if err == nil { + return category, nil + } + + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("GetOrCreateEmojiCategory: database error trying get emoji category by name: %s", err) + return nil, err + } + + // we don't have the category yet, just create it with the given name + categoryID, err := id.NewRandomULID() + if err != nil { + err = fmt.Errorf("GetOrCreateEmojiCategory: error generating id for new emoji category: %s", err) + return nil, err + } + + category = >smodel.EmojiCategory{ + ID: categoryID, + Name: name, + } + + if err := p.db.PutEmojiCategory(ctx, category); err != nil { + err = fmt.Errorf("GetOrCreateEmojiCategory: error putting new emoji category in the database: %s", err) + return nil, err + } + + return category, nil +} diff --git a/internal/processing/admin/getcategories.go b/internal/processing/admin/getcategories.go new file mode 100644 index 000000000..b38cc8f0c --- /dev/null +++ b/internal/processing/admin/getcategories.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package admin + +import ( + "context" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (p *processor) EmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode) { + categories, err := p.db.GetEmojiCategories(ctx) + if err != nil { + err := fmt.Errorf("EmojiCategoriesGet: db error: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiCategories := make([]*apimodel.EmojiCategory, 0, len(categories)) + for _, category := range categories { + apiCategory, err := p.tc.EmojiCategoryToAPIEmojiCategory(ctx, category) + if err != nil { + err := fmt.Errorf("EmojiCategoriesGet: error converting emoji category to api emoji category: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + apiCategories = append(apiCategories, apiCategory) + } + + return apiCategories, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index b7ab8504c..b7d42ffeb 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -119,6 +119,8 @@ type Processor interface { // AdminEmojiDelete deletes one *local* emoji with the given key. Remote emojis will not be deleted this way. // Only admin users in good standing should be allowed to access this function -- check this before calling it. AdminEmojiDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminEmoji, gtserror.WithCode) + // AdminEmojiCategoriesGet gets a list of all existing emoji categories. + AdminEmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode) // AdminDomainBlockCreate handles the creation of a new domain block by an admin, using the given form. AdminDomainBlockCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) // AdminDomainBlocksImport handles the import of multiple domain blocks by an admin, using the given form. diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 1ad7264ed..d797c3e0c 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -69,6 +69,8 @@ type TypeConverter interface { EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (model.Emoji, error) // EmojiToAdminAPIEmoji converts a gts model emoji into an API representation with extra admin information. EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error) + // EmojiCategoryToAPIEmojiCategory converts a gts model emoji category into its api (frontend) representation. + EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*model.EmojiCategory, error) // TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API. TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error) // StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API. diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index b2e2279bb..ac834f78d 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -356,12 +356,24 @@ func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention } func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (model.Emoji, error) { + var category string + if e.CategoryID != "" { + if e.Category == nil { + var err error + e.Category, err = c.db.GetEmojiCategory(ctx, e.CategoryID) + if err != nil { + return model.Emoji{}, err + } + } + category = e.Category.Name + } + return model.Emoji{ Shortcode: e.Shortcode, URL: e.ImageURL, StaticURL: e.ImageStaticURL, VisibleInPicker: *e.VisibleInPicker, - Category: e.CategoryID, + Category: category, }, nil } @@ -383,6 +395,13 @@ func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) }, nil } +func (c *converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*model.EmojiCategory, error) { + return &model.EmojiCategory{ + ID: category.ID, + Name: category.Name, + }, nil +} + func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error) { return model.Tag{ Name: t.Name, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 2db388302..29d998dcb 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -55,7 +55,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[],"enable_rss":true}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { @@ -70,7 +70,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[],"enable_rss":true}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { @@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { b, err := json.Marshal(apiStatus) suite.NoError(err) - suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) + suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() { @@ -148,7 +148,7 @@ func (suite *InternalToFrontendTestSuite) TestEmojiToFrontend() { b, err := json.Marshal(emoji) suite.NoError(err) - suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}`, string(b)) + suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin1() { @@ -158,7 +158,7 @@ func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin1() { b, err := json.Marshal(emoji) suite.NoError(err) - suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) + suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions","id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin2() { diff --git a/internal/validate/formvalidation.go b/internal/validate/formvalidation.go index ccf5e6504..5d6e3142c 100644 --- a/internal/validate/formvalidation.go +++ b/internal/validate/formvalidation.go @@ -42,6 +42,7 @@ maximumSiteTermsLength = 5000 maximumUsernameLength = 64 maximumCustomCSSLength = 5000 + maximumEmojiCategoryLength = 64 ) // NewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. @@ -182,6 +183,14 @@ func EmojiShortcode(shortcode string) error { return nil } +// EmojiCategory validates the length of the given category string. +func EmojiCategory(category string) error { + if length := len(category); length > maximumEmojiCategoryLength { + return fmt.Errorf("emoji category %s did not pass validation, must be less than %d characters, but provided value was %d characters", category, maximumEmojiCategoryLength, length) + } + return nil +} + // SiteTitle ensures that the given site title is within spec. func SiteTitle(siteTitle string) error { if length := len([]rune(siteTitle)); length > maximumSiteTitleLength { diff --git a/testrig/db.go b/testrig/db.go index 2e974f100..83f575a02 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -55,6 +55,7 @@ >smodel.RouterSession{}, >smodel.Token{}, >smodel.Client{}, + >smodel.EmojiCategory{}, >smodel.Tombstone{}, } @@ -199,6 +200,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { } } + for _, v := range NewTestEmojiCategories() { + if err := db.Put(ctx, v); err != nil { + log.Panic(err) + } + } + for _, v := range NewTestStatusToEmojis() { if err := db.Put(ctx, v); err != nil { log.Panic(err) diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 9f987eeae..6d29736a1 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -964,7 +964,7 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { Disabled: FalseBool(), URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ", VisibleInPicker: TrueBool(), - CategoryID: "", + CategoryID: "01GGQ8V4993XK67B2JB396YFB7", }, "yell": { ID: "01GD5KP5CQEE1R3X43Y1EHS2CW", @@ -991,6 +991,23 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { } } +func NewTestEmojiCategories() map[string]*gtsmodel.EmojiCategory { + return map[string]*gtsmodel.EmojiCategory{ + "reactions": { + ID: "01GGQ8V4993XK67B2JB396YFB7", + Name: "reactions", + CreatedAt: TimeMustParse("2020-03-18T11:40:55+02:00"), + UpdatedAt: TimeMustParse("2020-03-19T12:35:12+02:00"), + }, + "cute stuff": { + ID: "01GGQ989PTT9PMRN4FZ1WWK2B9", + Name: "cute stuff", + CreatedAt: TimeMustParse("2020-03-20T11:40:55+02:00"), + UpdatedAt: TimeMustParse("2020-03-21T12:35:12+02:00"), + }, + } +} + func NewTestStatusToEmojis() map[string]*gtsmodel.StatusToEmoji { return map[string]*gtsmodel.StatusToEmoji{ "admin_account_status_1_rainbow": {