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.