From f7416d6e941df6fe016d66bb5b53d633775c1f6f Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 14 Oct 2022 17:30:04 +0200 Subject: [PATCH] [feature] Add emoji DELETE handler at `/api/v1/admin/custom_emojis` (#913) * add emoji DELETE handler * no need to process error (thanks kim) * don't double check if user is admin * add missing security annotation --- docs/api/swagger.yaml | 39 +++++++ internal/api/client/admin/emojidelete.go | 110 ++++++++++++++++++ internal/api/client/admin/emojidelete_test.go | 101 ++++++++++++++++ internal/db/bundb/bundb_test.go | 2 + internal/db/bundb/emoji.go | 37 ++++++ internal/db/bundb/emoji_test.go | 11 ++ internal/db/emoji.go | 2 + internal/processing/admin.go | 4 + internal/processing/admin/admin.go | 1 + internal/processing/admin/deleteemoji.go | 59 ++++++++++ internal/processing/processor.go | 3 + 11 files changed, 369 insertions(+) create mode 100644 internal/api/client/admin/emojidelete.go create mode 100644 internal/api/client/admin/emojidelete_test.go create mode 100644 internal/processing/admin/deleteemoji.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index bef064102..8da5c17c5 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2862,6 +2862,45 @@ paths: tags: - admin /api/v1/admin/custom_emojis/{id}: + delete: + description: |- + Emoji with the given ID will no longer be available to use on the instance. + + If you just want to update the emoji image instead, use the `/api/v1/admin/custom_emojis/{id}` PATCH route. + + To disable emojis from **remote** instances, use the `/api/v1/admin/custom_emojis/{id}` PATCH route. + operationId: emojiDelete + parameters: + - description: The id of the emoji. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: The deleted emoji will be returned to the caller in case further processing is necessary. + 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: Delete a **local** emoji with the given ID from the instance. + tags: + - admin get: operationId: emojiGet parameters: diff --git a/internal/api/client/admin/emojidelete.go b/internal/api/client/admin/emojidelete.go new file mode 100644 index 000000000..14f3c70ff --- /dev/null +++ b/internal/api/client/admin/emojidelete.go @@ -0,0 +1,110 @@ +/* + 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" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// EmojiDELETEHandler swagger:operation DELETE /api/v1/admin/custom_emojis/{id} emojiDelete +// +// Delete a **local** emoji with the given ID from the instance. +// +// Emoji with the given ID will no longer be available to use on the instance. +// +// If you just want to update the emoji image instead, use the `/api/v1/admin/custom_emojis/{id}` PATCH route. +// +// To disable emojis from **remote** instances, use the `/api/v1/admin/custom_emojis/{id}` PATCH route. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the emoji. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The deleted emoji will be returned to the caller in case further processing is necessary. +// 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) EmojiDELETEHandler(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 + } + + emoji, errWithCode := m.processor.AdminEmojiDelete(c.Request.Context(), authed, emojiID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, emoji) +} diff --git a/internal/api/client/admin/emojidelete_test.go b/internal/api/client/admin/emojidelete_test.go new file mode 100644 index 000000000..c930c377a --- /dev/null +++ b/internal/api/client/admin/emojidelete_test.go @@ -0,0 +1,101 @@ +/* + 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" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/internal/db" +) + +type EmojiDeleteTestSuite struct { + AdminStandardTestSuite +} + +func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() { + recorder := httptest.NewRecorder() + testEmoji := suite.testEmojis["rainbow"] + + path := admin.EmojiPathWithID + ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") + ctx.AddParam(admin.IDKey, testEmoji.ID) + + suite.adminModule.EmojiDELETEHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + 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)) + + // emoji should no longer be in the db + dbEmoji, err := suite.db.GetEmojiByID(context.Background(), testEmoji.ID) + suite.Nil(dbEmoji) + suite.ErrorIs(err, db.ErrNoEntries) +} + +func (suite *EmojiDeleteTestSuite) TestEmojiDelete2() { + recorder := httptest.NewRecorder() + testEmoji := suite.testEmojis["yell"] + + path := admin.EmojiPathWithID + ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") + ctx.AddParam(admin.IDKey, testEmoji.ID) + + suite.adminModule.EmojiDELETEHandler(ctx) + suite.Equal(http.StatusBadRequest, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + + suite.Equal(`{"error":"Bad Request: EmojiDelete: emoji with id 01GD5KP5CQEE1R3X43Y1EHS2CW was not a local emoji, will not delete"}`, string(b)) + + // emoji should still be in the db + dbEmoji, err := suite.db.GetEmojiByID(context.Background(), testEmoji.ID) + suite.NoError(err) + suite.NotNil(dbEmoji) +} + +func (suite *EmojiDeleteTestSuite) TestEmojiDeleteNotFound() { + recorder := httptest.NewRecorder() + + path := admin.EmojiPathWithID + ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") + ctx.AddParam(admin.IDKey, "01GF8VRXX1R00X7XH8973Z29R1") + + suite.adminModule.EmojiDELETEHandler(ctx) + suite.Equal(http.StatusNotFound, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + suite.Equal(`{"error":"Not Found"}`, string(b)) +} + +func TestEmojiDeleteTestSuite(t *testing.T) { + suite.Run(t, &EmojiDeleteTestSuite{}) +} diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 2af6cf122..b05df8b74 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -41,6 +41,7 @@ type BunDBStandardTestSuite struct { testTags map[string]*gtsmodel.Tag testMentions map[string]*gtsmodel.Mention testFollows map[string]*gtsmodel.Follow + testEmojis map[string]*gtsmodel.Emoji } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -54,6 +55,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testTags = testrig.NewTestTags() suite.testMentions = testrig.NewTestMentions() suite.testFollows = testrig.NewTestFollows() + suite.testEmojis = testrig.NewTestEmojis() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go index 4fb4f0ce6..51d767a7b 100644 --- a/internal/db/bundb/emoji.go +++ b/internal/db/bundb/emoji.go @@ -68,6 +68,43 @@ func (e *emojiDB) UpdateEmoji(ctx context.Context, emoji *gtsmodel.Emoji, column return emoji, nil } +func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) db.Error { + if err := e.conn.RunInTx(ctx, func(tx bun.Tx) error { + // delete links between this emoji and any statuses that use it + if _, err := tx. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_to_emojis"), bun.Ident("status_to_emoji")). + Where("? = ?", bun.Ident("status_to_emoji.emoji_id"), id). + Exec(ctx); err != nil { + return err + } + + // delete links between this emoji and any accounts that use it + if _, err := tx. + NewDelete(). + TableExpr("? AS ?", bun.Ident("account_to_emojis"), bun.Ident("account_to_emoji")). + Where("? = ?", bun.Ident("account_to_emoji.emoji_id"), id). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewDelete(). + TableExpr("? AS ?", bun.Ident("emojis"), bun.Ident("emoji")). + Where("? = ?", bun.Ident("emoji.id"), id). + Exec(ctx); err != nil { + return e.conn.ProcessError(err) + } + + return nil + }); err != nil { + return err + } + + e.cache.Invalidate(id) + return nil +} + func (e *emojiDB) GetEmojis(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, db.Error) { emojiIDs := []string{} diff --git a/internal/db/bundb/emoji_test.go b/internal/db/bundb/emoji_test.go index c6577a721..b542f9b67 100644 --- a/internal/db/bundb/emoji_test.go +++ b/internal/db/bundb/emoji_test.go @@ -38,6 +38,17 @@ func (suite *EmojiTestSuite) TestGetUseableEmojis() { suite.Equal("rainbow", emojis[0].Shortcode) } +func (suite *EmojiTestSuite) TestDeleteEmojiByID() { + testEmoji := suite.testEmojis["rainbow"] + + err := suite.db.DeleteEmojiByID(context.Background(), testEmoji.ID) + suite.NoError(err) + + dbEmoji, err := suite.db.GetEmojiByID(context.Background(), testEmoji.ID) + suite.Nil(dbEmoji) + suite.ErrorIs(err, db.ErrNoEntries) +} + func (suite *EmojiTestSuite) TestGetEmojiByStaticURL() { emoji, err := suite.db.GetEmojiByStaticURL(context.Background(), "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png") suite.NoError(err) diff --git a/internal/db/emoji.go b/internal/db/emoji.go index 831629232..d2f66a377 100644 --- a/internal/db/emoji.go +++ b/internal/db/emoji.go @@ -35,6 +35,8 @@ type Emoji interface { // UpdateEmoji updates the given columns of one emoji. // If no columns are specified, every column is updated. UpdateEmoji(ctx context.Context, emoji *gtsmodel.Emoji, columns ...string) (*gtsmodel.Emoji, Error) + // DeleteEmojiByID deletes one emoji by its database ID. + DeleteEmojiByID(ctx context.Context, id string) Error // GetUseableEmojis gets all emojis which are useable by accounts on this instance. GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, Error) // GetEmojis gets emojis based on given parameters. Useful for admin actions. diff --git a/internal/processing/admin.go b/internal/processing/admin.go index 0ebce4d4e..38ed0905f 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) AdminEmojiDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminEmoji, gtserror.WithCode) { + return p.adminProcessor.EmojiDelete(ctx, id) +} + 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 49c02d3db..962a3ac7c 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -43,6 +43,7 @@ type Processor interface { EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode) 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) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode } diff --git a/internal/processing/admin/deleteemoji.go b/internal/processing/admin/deleteemoji.go new file mode 100644 index 000000000..8d5e32094 --- /dev/null +++ b/internal/processing/admin/deleteemoji.go @@ -0,0 +1,59 @@ +/* + 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" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (p *processor) EmojiDelete(ctx context.Context, id string) (*apimodel.AdminEmoji, gtserror.WithCode) { + emoji, err := p.db.GetEmojiByID(ctx, id) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("EmojiDelete: no emoji with id %s found in the db", id) + return nil, gtserror.NewErrorNotFound(err) + } + err := fmt.Errorf("EmojiDelete: db error: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if emoji.Domain != "" { + err = fmt.Errorf("EmojiDelete: emoji with id %s was not a local emoji, will not delete", id) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, emoji) + if err != nil { + err = fmt.Errorf("EmojiDelete: error converting emoji to admin api emoji: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if err := p.db.DeleteEmojiByID(ctx, id); err != nil { + err := fmt.Errorf("EmojiDelete: db error: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return adminEmoji, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index ff465c926..b7ab8504c 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -116,6 +116,9 @@ type Processor interface { AdminEmojisGet(ctx context.Context, authed *oauth.Auth, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) // AdminEmojiGet returns the admin view of an emoji with the given ID AdminEmojiGet(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminEmoji, gtserror.WithCode) + // 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) // 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.