From b6dbe21026615ef3fbaacff98c7cc860cef39d16 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 24 Nov 2022 19:12:07 +0100 Subject: [PATCH] [feature] `PATCH /api/v1/admin/custom_emojis/{id}` endpoint (#1061) * start adding admin emoji PATCH stuff * updating works OK, now how about copying * allow emojis to be copied * update swagger docs * update admin processer to use non-interface storage driver * remove shortcode updating for local emojis * go fmt Co-authored-by: f0x52 --- docs/api/swagger.yaml | 92 ++- internal/api/client/admin/admin.go | 1 + internal/api/client/admin/emojiupdate.go | 221 +++++++ internal/api/client/admin/emojiupdate_test.go | 549 ++++++++++++++++++ internal/api/model/emoji.go | 25 + internal/media/processingemoji.go | 3 +- internal/processing/admin.go | 4 + internal/processing/admin/admin.go | 6 +- internal/processing/admin/updateemoji.go | 237 ++++++++ internal/processing/processor.go | 5 +- 10 files changed, 1139 insertions(+), 4 deletions(-) create mode 100644 internal/api/client/admin/emojiupdate.go create mode 100644 internal/api/client/admin/emojiupdate_test.go create mode 100644 internal/processing/admin/updateemoji.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 4fd9b9ffb..a90409344 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -1,5 +1,9 @@ basePath: / definitions: + EmojiUpdateType: + title: EmojiUpdateType models an admin update action to take on a custom emoji. + type: string + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model InstanceConfigurationAccounts: properties: allow_custom_css: @@ -256,7 +260,6 @@ definitions: description: |- Role of the account on this instance. Omitted for remote accounts. - type: accountRole example: user type: string x-go-name: Role @@ -898,6 +901,25 @@ definitions: type: object x-go-name: EmojiCreateRequest x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + emojiUpdateRequest: + properties: + CategoryName: + description: Category in which to place the emoji. + type: string + Image: + description: |- + Image file to use for the emoji. + Must be png or gif and no larger than 50kb. + Shortcode: + description: Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. + example: blobcat_uwu + type: string + type: + $ref: '#/definitions/EmojiUpdateType' + title: EmojiUpdateRequest represents a request to update a custom emoji, made through the admin API. + type: object + x-go-name: EmojiUpdateRequest + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model field: properties: name: @@ -2883,6 +2905,74 @@ paths: summary: Get the admin view of a single emoji. tags: - admin + patch: + consumes: + - multipart/form-data + description: |- + Action performed depends upon the action `type` provided. + + `disable`: disable a REMOTE emoji from being used/displayed on this instance. Does not work for local emojis. + + `copy`: copy a REMOTE emoji to this instance. When doing this action, a shortcode MUST be provided, and it must + be unique among emojis already present on this instance. A category MAY be provided, and the copied emoji will then + be put into the provided category. + + `modify`: modify a LOCAL emoji. You can provide a new image for the emoji and/or update the category. + + Local emojis cannot be deleted using this endpoint. To delete a local emoji, check DELETE /api/v1/admin/custom_emojis/{id} instead. + operationId: emojiUpdate + parameters: + - description: The id of the emoji. + in: path + name: id + required: true + type: string + - description: |- + Type of action to be taken. One of: (`disable`, `copy`, `modify`). + For REMOTE emojis, `copy` or `disable` are supported. + For LOCAL emojis, only `modify` is supported. + in: formData + name: type + required: true + type: string + - description: The code to use for the emoji, which will be used by instance denizens to select it. This must be unique on the instance. Works for the `copy` action type only. + in: formData + name: shortcode + pattern: \w{2,30} + type: string + - description: A new png or gif image to use for the emoji. Animated pngs work too! To ensure compatibility with other fedi implementations, emoji size limit is 50kb by default. Works for LOCAL emojis only. + in: formData + name: image + type: file + - description: Category in which to place the emoji. 64 characters or less. 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: + "200": + description: The updated emoji. + schema: + $ref: '#/definitions/adminEmoji' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Perform admin action on a local or remote emoji known to this instance. + tags: + - admin /api/v1/admin/custom_emojis/categories: get: operationId: emojiCategoriesGet diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index e34bac1cf..7032a5b95 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -83,6 +83,7 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler) r.AttachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler) r.AttachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler) + r.AttachHandler(http.MethodPatch, EmojiPathWithID, m.EmojiPATCHHandler) r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler) r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler) r.AttachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) diff --git a/internal/api/client/admin/emojiupdate.go b/internal/api/client/admin/emojiupdate.go new file mode 100644 index 000000000..695c6bcde --- /dev/null +++ b/internal/api/client/admin/emojiupdate.go @@ -0,0 +1,221 @@ +/* + 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 ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// EmojiPATCHHandler swagger:operation PATCH /api/v1/admin/custom_emojis/{id} emojiUpdate +// +// Perform admin action on a local or remote emoji known to this instance. +// +// Action performed depends upon the action `type` provided. +// +// `disable`: disable a REMOTE emoji from being used/displayed on this instance. Does not work for local emojis. +// +// `copy`: copy a REMOTE emoji to this instance. When doing this action, a shortcode MUST be provided, and it must +// be unique among emojis already present on this instance. A category MAY be provided, and the copied emoji will then +// be put into the provided category. +// +// `modify`: modify a LOCAL emoji. You can provide a new image for the emoji and/or update the category. +// +// Local emojis cannot be deleted using this endpoint. To delete a local emoji, check DELETE /api/v1/admin/custom_emojis/{id} instead. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the emoji. +// in: path +// required: true +// - +// name: type +// in: formData +// description: |- +// Type of action to be taken. One of: (`disable`, `copy`, `modify`). +// For REMOTE emojis, `copy` or `disable` are supported. +// For LOCAL emojis, only `modify` is supported. +// type: string +// required: true +// - +// name: shortcode +// in: formData +// description: >- +// The code to use for the emoji, which will be used by instance denizens to select it. +// This must be unique on the instance. Works for the `copy` action type only. +// type: string +// pattern: \w{2,30} +// - +// name: image +// in: formData +// description: >- +// A new png or gif image to use for the emoji. Animated pngs work too! +// To ensure compatibility with other fedi implementations, emoji size limit is 50kb by default. +// Works for LOCAL emojis only. +// type: file +// - +// name: category +// in: formData +// description: >- +// Category in which to place the emoji. 64 characters or less. +// If a category with the given name doesn't exist yet, it will be created. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The updated emoji. +// schema: +// "$ref": "#/definitions/adminEmoji" +// '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) EmojiPATCHHandler(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 + } + + emojiID := c.Param(IDKey) + if emojiID == "" { + err := errors.New("no emoji id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &model.EmojiUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if err := validateUpdateEmoji(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + emoji, errWithCode := m.processor.AdminEmojiUpdate(c.Request.Context(), emojiID, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, emoji) +} + +// do a first pass on the form here +func validateUpdateEmoji(form *model.EmojiUpdateRequest) error { + // check + normalize update type so we don't need + // to do this trimming + lowercasing again later + switch strings.TrimSpace(strings.ToLower(string(form.Type))) { + case string(model.EmojiUpdateDisable): + // no params required for this one, so don't bother checking + form.Type = model.EmojiUpdateDisable + case string(model.EmojiUpdateCopy): + // need at least a valid shortcode when doing a copy + if form.Shortcode == nil { + return errors.New("emoji action type was 'copy' but no shortcode was provided") + } + + if err := validate.EmojiShortcode(*form.Shortcode); err != nil { + return err + } + + // category optional during copy + if form.CategoryName != nil { + if err := validate.EmojiCategory(*form.CategoryName); err != nil { + return err + } + } + + form.Type = model.EmojiUpdateCopy + case string(model.EmojiUpdateModify): + // need either image or category name for modify + hasImage := form.Image != nil && form.Image.Size != 0 + hasCategoryName := form.CategoryName != nil + if !hasImage && !hasCategoryName { + return errors.New("emoji action type was 'modify' but no image or category name was provided") + } + + if hasImage { + maxSize := config.GetMediaEmojiLocalMaxSize() + if form.Image.Size > int64(maxSize) { + return fmt.Errorf("emoji image too large: image is %dKB but size limit for custom emojis is %dKB", form.Image.Size/1024, maxSize/1024) + } + } + + if hasCategoryName { + if err := validate.EmojiCategory(*form.CategoryName); err != nil { + return err + } + } + + form.Type = model.EmojiUpdateModify + default: + return errors.New("emoji action type must be one of 'disable', 'copy', 'modify'") + } + + return nil +} diff --git a/internal/api/client/admin/emojiupdate_test.go b/internal/api/client/admin/emojiupdate_test.go new file mode 100644 index 000000000..9a7d924ff --- /dev/null +++ b/internal/api/client/admin/emojiupdate_test.go @@ -0,0 +1,549 @@ +/* + 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 ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmojiUpdateTestSuite struct { + AdminStandardTestSuite +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["rainbow"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "category": "New Category", // this category doesn't exist yet + "type": "modify", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(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 admin model emoji + adminEmoji := &apimodel.AdminEmoji{} + err = json.Unmarshal(b, adminEmoji) + suite.NoError(err) + + // appropriate fields should be set + suite.Equal("rainbow", adminEmoji.Shortcode) + suite.NotEmpty(adminEmoji.URL) + suite.NotEmpty(adminEmoji.StaticURL) + suite.True(adminEmoji.VisibleInPicker) + + // emoji should be in the db + dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") + suite.NoError(err) + + // check fields on the emoji + suite.NotEmpty(dbEmoji.ID) + suite.Equal("rainbow", dbEmoji.Shortcode) + suite.Empty(dbEmoji.Domain) + suite.Empty(dbEmoji.ImageRemoteURL) + suite.Empty(dbEmoji.ImageStaticRemoteURL) + suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) + suite.Equal(adminEmoji.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 *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["rainbow"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "modify", + "category": "cute stuff", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(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 admin model emoji + adminEmoji := &apimodel.AdminEmoji{} + err = json.Unmarshal(b, adminEmoji) + suite.NoError(err) + + // appropriate fields should be set + suite.Equal("rainbow", adminEmoji.Shortcode) + suite.NotEmpty(adminEmoji.URL) + suite.NotEmpty(adminEmoji.StaticURL) + suite.True(adminEmoji.VisibleInPicker) + + // emoji should be in the db + dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") + suite.NoError(err) + + // check fields on the emoji + suite.NotEmpty(dbEmoji.ID) + suite.Equal("rainbow", dbEmoji.Shortcode) + suite.Empty(dbEmoji.Domain) + suite.Empty(dbEmoji.ImageRemoteURL) + suite.Empty(dbEmoji.ImageStaticRemoteURL) + suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) + suite.Equal(adminEmoji.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 *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "copy", + "category": "emojis i stole", + "shortcode": "yell", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(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 admin model emoji + adminEmoji := &apimodel.AdminEmoji{} + err = json.Unmarshal(b, adminEmoji) + suite.NoError(err) + + // appropriate fields should be set + suite.Equal("yell", adminEmoji.Shortcode) + suite.NotEmpty(adminEmoji.URL) + suite.NotEmpty(adminEmoji.StaticURL) + suite.True(adminEmoji.VisibleInPicker) + + // emoji should be in the db + dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") + suite.NoError(err) + + // check fields on the emoji + suite.NotEmpty(dbEmoji.ID) + suite.Equal("yell", dbEmoji.Shortcode) + suite.Empty(dbEmoji.Domain) + suite.Empty(dbEmoji.ImageRemoteURL) + suite.Empty(dbEmoji.ImageStaticRemoteURL) + suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) + suite.Equal(adminEmoji.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(10889, dbEmoji.ImageFileSize) + suite.Equal(10672, 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 *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "disable", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(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 admin model emoji + adminEmoji := &apimodel.AdminEmoji{} + err = json.Unmarshal(b, adminEmoji) + suite.NoError(err) + + suite.True(adminEmoji.Disabled) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["rainbow"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "disable", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: emojiUpdateDisable: emoji 01F8MH9H8E4VG3KDYJR9EGPXCQ is not a remote emoji, cannot disable it via this endpoint"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "image", "../../../../testrig/media/kip-original.gif", + map[string]string{ + "type": "modify", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: emojiUpdateModify: emoji 01GD5KP5CQEE1R3X43Y1EHS2CW is not a local emoji, cannot do a modify action on it"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["rainbow"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "modify", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: emoji action type was 'modify' but no image or category name was provided"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["rainbow"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "copy", + "shortcode": "bottoms", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: emojiUpdateCopy: emoji 01F8MH9H8E4VG3KDYJR9EGPXCQ is not a remote emoji, cannot copy it to local"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "copy", + "shortcode": "", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: shortcode did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "copy", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusBadRequest, 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.Equal(`{"error":"Bad Request: emoji action type was 'copy' but no shortcode was provided"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() { + testEmoji := >smodel.Emoji{} + *testEmoji = *suite.testEmojis["yell"] + + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "type": "copy", + "shortcode": "rainbow", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) + ctx.AddParam(admin.IDKey, testEmoji.ID) + + // call the handler + suite.adminModule.EmojiPATCHHandler(ctx) + suite.Equal(http.StatusConflict, 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.Equal(`{"error":"Conflict: emojiUpdateCopy: emoji 01GD5KP5CQEE1R3X43Y1EHS2CW could not be copied, emoji with shortcode rainbow already exists on this instance"}`, string(b)) +} + +func TestEmojiUpdateTestSuite(t *testing.T) { + suite.Run(t, &EmojiUpdateTestSuite{}) +} diff --git a/internal/api/model/emoji.go b/internal/api/model/emoji.go index eb636f63c..2c75ceadd 100644 --- a/internal/api/model/emoji.go +++ b/internal/api/model/emoji.go @@ -54,3 +54,28 @@ type EmojiCreateRequest struct { // CategoryName length should not exceed 64 characters. CategoryName string `form:"category"` } + +// EmojiUpdateRequest represents a request to update a custom emoji, made through the admin API. +// +// swagger:model emojiUpdateRequest +type EmojiUpdateRequest struct { + // Type of action. One of disable, modify, copy. + Type EmojiUpdateType `form:"type" json:"type" xml:"type"` + // Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. + // example: blobcat_uwu + Shortcode *string `form:"shortcode"` + // Image file to use for the emoji. + // Must be png or gif and no larger than 50kb. + Image *multipart.FileHeader `form:"image"` + // Category in which to place the emoji. + CategoryName *string `form:"category"` +} + +// EmojiUpdateType models an admin update action to take on a custom emoji. +type EmojiUpdateType string + +const ( + EmojiUpdateModify EmojiUpdateType = "modify" // modify local emoji + EmojiUpdateDisable EmojiUpdateType = "disable" // disable remote emoji + EmojiUpdateCopy EmojiUpdateType = "copy" // copy remote emoji -> local +) diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index a660ad775..ad3f02a53 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -115,6 +115,7 @@ func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error "image_file_size", "image_static_file_size", "image_updated_at", + "shortcode", "uri", } if _, err := p.database.UpdateEmoji(ctx, p.emoji, columns...); err != nil { @@ -340,7 +341,7 @@ func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, postData P emoji.ImageStaticURL = uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), newPathID, mimePng) emoji.ImageStaticPath = fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeStatic, newPathID, mimePng) - // update these fields as we go + emoji.Shortcode = shortcode emoji.URI = uri } else { disabled := false diff --git a/internal/processing/admin.go b/internal/processing/admin.go index f10e9d64a..7b0933c0e 100644 --- a/internal/processing/admin.go +++ b/internal/processing/admin.go @@ -42,6 +42,10 @@ func (p *processor) AdminEmojiGet(ctx context.Context, authed *oauth.Auth, id st return p.adminProcessor.EmojiGet(ctx, authed.Account, authed.User, id) } +func (p *processor) AdminEmojiUpdate(ctx context.Context, id string, form *apimodel.EmojiUpdateRequest) (*apimodel.AdminEmoji, gtserror.WithCode) { + return p.adminProcessor.EmojiUpdate(ctx, id, form) +} + func (p *processor) AdminEmojiDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminEmoji, gtserror.WithCode) { return p.adminProcessor.EmojiDelete(ctx, id) } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 0e8f0c27a..f04d322ad 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -44,6 +45,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) + EmojiUpdate(ctx context.Context, id string, form *apimodel.EmojiUpdateRequest) (*apimodel.AdminEmoji, gtserror.WithCode) EmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode } @@ -51,15 +53,17 @@ type Processor interface { type processor struct { tc typeutils.TypeConverter mediaManager media.Manager + storage *storage.Driver clientWorker *concurrency.WorkerPool[messages.FromClientAPI] db db.DB } // New returns a new admin processor. -func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, clientWorker *concurrency.WorkerPool[messages.FromClientAPI]) Processor { +func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, storage *storage.Driver, clientWorker *concurrency.WorkerPool[messages.FromClientAPI]) Processor { return &processor{ tc: tc, mediaManager: mediaManager, + storage: storage, clientWorker: clientWorker, db: db, } diff --git a/internal/processing/admin/updateemoji.go b/internal/processing/admin/updateemoji.go new file mode 100644 index 000000000..1a86d5080 --- /dev/null +++ b/internal/processing/admin/updateemoji.go @@ -0,0 +1,237 @@ +/* + 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" + "io" + "mime/multipart" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "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" +) + +func (p *processor) EmojiUpdate(ctx context.Context, id string, form *apimodel.EmojiUpdateRequest) (*apimodel.AdminEmoji, gtserror.WithCode) { + emoji, err := p.db.GetEmojiByID(ctx, id) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("EmojiUpdate: no emoji with id %s found in the db", id) + return nil, gtserror.NewErrorNotFound(err) + } + err := fmt.Errorf("EmojiUpdate: db error: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + switch form.Type { + case apimodel.EmojiUpdateCopy: + return p.emojiUpdateCopy(ctx, emoji, form.Shortcode, form.CategoryName) + case apimodel.EmojiUpdateDisable: + return p.emojiUpdateDisable(ctx, emoji) + case apimodel.EmojiUpdateModify: + return p.emojiUpdateModify(ctx, emoji, form.Image, form.CategoryName) + default: + err := errors.New("unrecognized emoji action type") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } +} + +// copy an emoji from remote to local +func (p *processor) emojiUpdateCopy(ctx context.Context, emoji *gtsmodel.Emoji, shortcode *string, categoryName *string) (*apimodel.AdminEmoji, gtserror.WithCode) { + if emoji.Domain == "" { + err := fmt.Errorf("emojiUpdateCopy: emoji %s is not a remote emoji, cannot copy it to local", emoji.ID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + if shortcode == nil { + err := fmt.Errorf("emojiUpdateCopy: emoji %s could not be copied, no shortcode provided", emoji.ID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + maybeExisting, err := p.db.GetEmojiByShortcodeDomain(ctx, *shortcode, "") + if maybeExisting != nil { + err := fmt.Errorf("emojiUpdateCopy: emoji %s could not be copied, emoji with shortcode %s already exists on this instance", emoji.ID, *shortcode) + return nil, gtserror.NewErrorConflict(err, err.Error()) + } + + if err != nil && err != db.ErrNoEntries { + err := fmt.Errorf("emojiUpdateCopy: emoji %s could not be copied, error checking existence of emoji with shortcode %s: %s", emoji.ID, *shortcode, err) + return nil, gtserror.NewErrorInternalError(err) + } + + newEmojiID, err := id.NewRandomULID() + if err != nil { + err := fmt.Errorf("emojiUpdateCopy: emoji %s could not be copied, error creating id for new emoji: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + newEmojiURI := uris.GenerateURIForEmoji(newEmojiID) + + data := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) { + // 'copy' the emoji by pulling the existing one out of storage + i, err := p.storage.GetStream(ctx, emoji.ImagePath) + return i, int64(emoji.ImageFileSize), err + } + + var ai *media.AdditionalEmojiInfo + if categoryName != nil { + category, err := p.GetOrCreateEmojiCategory(ctx, *categoryName) + if err != nil { + err = fmt.Errorf("emojiUpdateCopy: error getting or creating category: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + ai = &media.AdditionalEmojiInfo{ + CategoryID: &category.ID, + } + } + + processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, nil, *shortcode, newEmojiID, newEmojiURI, ai, false) + if err != nil { + err = fmt.Errorf("emojiUpdateCopy: error processing emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + newEmoji, err := processingEmoji.LoadEmoji(ctx) + if err != nil { + err = fmt.Errorf("emojiUpdateCopy: error loading processed emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, newEmoji) + if err != nil { + err = fmt.Errorf("emojiUpdateCopy: error converting updated emoji %s to admin emoji: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return adminEmoji, nil +} + +// disable a remote emoji +func (p *processor) emojiUpdateDisable(ctx context.Context, emoji *gtsmodel.Emoji) (*apimodel.AdminEmoji, gtserror.WithCode) { + if emoji.Domain == "" { + err := fmt.Errorf("emojiUpdateDisable: emoji %s is not a remote emoji, cannot disable it via this endpoint", emoji.ID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + emojiDisabled := true + emoji.Disabled = &emojiDisabled + updatedEmoji, err := p.db.UpdateEmoji(ctx, emoji, "updated_at", "disabled") + if err != nil { + err = fmt.Errorf("emojiUpdateDisable: error updating emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, updatedEmoji) + if err != nil { + err = fmt.Errorf("emojiUpdateDisable: error converting updated emoji %s to admin emoji: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return adminEmoji, nil +} + +// modify a local emoji +func (p *processor) emojiUpdateModify(ctx context.Context, emoji *gtsmodel.Emoji, image *multipart.FileHeader, categoryName *string) (*apimodel.AdminEmoji, gtserror.WithCode) { + if emoji.Domain != "" { + err := fmt.Errorf("emojiUpdateModify: emoji %s is not a local emoji, cannot do a modify action on it", emoji.ID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + var updatedEmoji *gtsmodel.Emoji + + // keep existing categoryID unless a new one is defined + var ( + updatedCategoryID = emoji.CategoryID + updateCategoryID bool + ) + if categoryName != nil { + category, err := p.GetOrCreateEmojiCategory(ctx, *categoryName) + if err != nil { + err = fmt.Errorf("emojiUpdateModify: error getting or creating category: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + updatedCategoryID = category.ID + updateCategoryID = true + } + + // only update image if provided with one + var updateImage bool + if image != nil && image.Size != 0 { + updateImage = true + } + + if !updateImage { + // only updating fields, we only need + // to do a database update for this + columns := []string{"updated_at"} + + if updateCategoryID { + emoji.CategoryID = updatedCategoryID + columns = append(columns, "category_id") + } + + var err error + updatedEmoji, err = p.db.UpdateEmoji(ctx, emoji, columns...) + if err != nil { + err = fmt.Errorf("emojiUpdateModify: error updating emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + } else { + // new image, so we need to reprocess the emoji + data := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) { + i, err := image.Open() + return i, image.Size, err + } + + var ai *media.AdditionalEmojiInfo + if updateCategoryID { + ai = &media.AdditionalEmojiInfo{ + CategoryID: &updatedCategoryID, + } + } + + processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, nil, emoji.Shortcode, emoji.ID, emoji.URI, ai, true) + if err != nil { + err = fmt.Errorf("emojiUpdateModify: error processing emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + updatedEmoji, err = processingEmoji.LoadEmoji(ctx) + if err != nil { + err = fmt.Errorf("emojiUpdateModify: error loading processed emoji %s: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, updatedEmoji) + if err != nil { + err = fmt.Errorf("emojiUpdateModify: error converting updated emoji %s to admin emoji: %s", emoji.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return adminEmoji, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 686cb5015..22fb7b2b7 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -119,6 +119,9 @@ 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) + // AdminEmojiUpdate updates one local or remote emoji with the given key. + // Only admin users in good standing should be allowed to access this function -- check this before calling it. + AdminEmojiUpdate(ctx context.Context, id string, form *apimodel.EmojiUpdateRequest) (*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. @@ -308,7 +311,7 @@ func NewProcessor( statusProcessor := status.New(db, tc, clientWorker, parseMentionFunc) streamingProcessor := streaming.New(db, oauthServer) accountProcessor := account.New(db, tc, mediaManager, oauthServer, clientWorker, federator, parseMentionFunc) - adminProcessor := admin.New(db, tc, mediaManager, clientWorker) + adminProcessor := admin.New(db, tc, mediaManager, storage, clientWorker) mediaProcessor := mediaProcessor.New(db, tc, mediaManager, federator.TransportController(), storage) userProcessor := user.New(db, emailSender) federationProcessor := federationProcessor.New(db, tc, federator)