diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index e09d07f14..5330d080f 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -407,6 +407,73 @@ definitions: type: object x-go-name: Relationship x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + adminEmoji: + properties: + category: + description: Used for sorting custom emoji in the picker. + example: blobcats + type: string + x-go-name: Category + content_type: + description: The MIME content type of the emoji. + example: image/png + type: string + x-go-name: ContentType + disabled: + description: True if this emoji has been disabled by an admin action. + example: false + type: boolean + x-go-name: Disabled + domain: + description: The domain from which the emoji originated. Only defined for remote domains, otherwise key will not be set. + example: example.org + type: string + x-go-name: Domain + id: + description: The ID of the emoji. + example: 01GEM7SFDZ7GZNRXFVZ3X4E4N1 + type: string + x-go-name: ID + shortcode: + description: The name of the custom emoji. + example: blobcat_uwu + type: string + x-go-name: Shortcode + static_url: + description: A link to a static copy of the custom emoji. + example: https://example.org/fileserver/emojis/blogcat_uwu.png + type: string + x-go-name: StaticURL + total_file_size: + description: The total file size taken up by the emoji in bytes, including static and animated versions. + example: 69420 + format: int64 + type: integer + x-go-name: TotalFileSize + updated_at: + description: Time when the emoji image was last updated. + example: "2022-10-05T09:21:26.419Z" + type: string + x-go-name: UpdatedAt + uri: + description: The ActivityPub URI of the emoji. + example: https://example.org/emojis/016T5Q3SQKBT337DAKVSKNXXW1 + type: string + x-go-name: URI + url: + description: Web URL of the custom emoji. + example: https://example.org/fileserver/emojis/blogcat_uwu.gif + type: string + x-go-name: URL + visible_in_picker: + description: Emoji is visible in the emoji picker of the instance. + example: true + type: boolean + x-go-name: VisibleInPicker + title: AdminEmoji models the admin view of a custom emoji. + type: object + x-go-name: AdminEmoji + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model advancedStatusCreateForm: description: |- AdvancedStatusCreateForm wraps the mastodon-compatible status create form along with the GTS advanced @@ -2677,6 +2744,80 @@ paths: tags: - admin /api/v1/admin/custom_emojis: + get: + description: |- + The next and previous queries can be parsed from the returned Link header. + Example: + + `; rel="next", ; rel="prev"` + operationId: emojisGet + parameters: + - default: domain:all + description: |- + Comma-separated list of filters to apply to results. Recognized filters are: + + `domain:[domain]` -- show emojis from the given domain, eg `?filter=domain:example.org` will show emojis from `example.org` only. + Instead of giving a specific domain, you can also give either one of the key words `local` or `all` to show either local emojis only (`domain:local`) or show all emojis from all domains (`domain:all`). + Note: `domain:*` is equivalent to `domain:all` (including local). + If no domain filter is provided, `domain:all` will be assumed. + + `disabled` -- include emojis that have been disabled. + + `enabled` -- include emojis that are enabled. + + `shortcode:[shortcode]` -- show only emojis with the given shortcode, eg `?filter=shortcode:blob_cat_uwu` will show only emojis with the shortcode `blob_cat_uwu` (case sensitive). + + If neither `disabled` or `enabled` are provided, both disabled and enabled emojis will be shown. + + If no filter query string is provided, the default `domain:all` will be used, which will show all emojis from all domains. + in: query + name: filter + type: string + - default: 30 + description: Number of emojis to return. If below 1, will be set to 1, if greater than 50, will be set to 50. + in: query + name: limit + type: integer + - description: |- + Return only emojis with `[shortcode]@[domain]` *LOWER* (alphabetically) than given `[shortcode]@[domain]`. For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with `[shortcode]@[domain]`s like `car@example.org`, `debian@aaa.com`, `test@` (local emoji), etc. + Emoji with the given `[shortcode]@[domain]` will not be included in the result set. + in: query + name: max_shortcode_domain + type: string + - description: |- + Return only emojis with `[shortcode]@[domain]` *HIGHER* (alphabetically) than given `[shortcode]@[domain]`. For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with `[shortcode]@[domain]`s like `arse@test.com`, `0101_binary@hackers.net`, `bee@` (local emoji), etc. + Emoji with the given `[shortcode]@[domain]` will not be included in the result set. + in: query + name: min_shortcode_domain + type: string + produces: + - application/json + responses: + "200": + description: An array of emojis, arranged alphabetically by shortcode and domain. + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/adminEmoji' + 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: View local and remote emojis available to / known by this instance. + tags: + - admin post: consumes: - multipart/form-data diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 2044c4ab0..e8aac0dee 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -31,6 +31,8 @@ const ( BasePath = "/api/v1/admin" // EmojiPath is used for posting/deleting custom emojis. EmojiPath = BasePath + "/custom_emojis" + // EmojiPathWithID is used for interacting with a single emoji. + EmojiPathWithID = EmojiPath + "/:" + IDKey // DomainBlocksPath is used for posting domain blocks. DomainBlocksPath = BasePath + "/domain_blocks" // DomainBlocksPathWithID is used for interacting with a single domain block. @@ -49,6 +51,16 @@ const ( ImportQueryKey = "import" // IDKey specifies the ID of a single item being interacted with. IDKey = "id" + // FilterKey is for applying filters to admin views of accounts, emojis, etc. + FilterQueryKey = "filter" + // MaxShortcodeDomainKey is the url query for returning emoji results lower (alphabetically) + // than the given `[shortcode]@[domain]` parameter. + MaxShortcodeDomainKey = "max_shortcode_domain" + // MaxShortcodeDomainKey is the url query for returning emoji results higher (alphabetically) + // than the given `[shortcode]@[domain]` parameter. + MinShortcodeDomainKey = "min_shortcode_domain" + // LimitKey is for specifying maximum number of results to return. + LimitKey = "limit" ) // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc) @@ -66,6 +78,7 @@ func New(processor processing.Processor) api.ClientModule { // Route attaches all routes from this module to the given router func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler) + r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler) 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/emojiget.go b/internal/api/client/admin/emojiget.go new file mode 100644 index 000000000..7c44f45d4 --- /dev/null +++ b/internal/api/client/admin/emojiget.go @@ -0,0 +1,211 @@ +/* + 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" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// EmojisGETHandler swagger:operation GET /api/v1/admin/custom_emojis emojisGet +// +// View local and remote emojis available to / known by this instance. +// +// The next and previous queries can be parsed from the returned Link header. +// Example: +// +// `; rel="next", ; rel="prev"` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: filter +// type: string +// description: |- +// Comma-separated list of filters to apply to results. Recognized filters are: +// +// `domain:[domain]` -- show emojis from the given domain, eg `?filter=domain:example.org` will show emojis from `example.org` only. +// Instead of giving a specific domain, you can also give either one of the key words `local` or `all` to show either local emojis only (`domain:local`) or show all emojis from all domains (`domain:all`). +// Note: `domain:*` is equivalent to `domain:all` (including local). +// If no domain filter is provided, `domain:all` will be assumed. +// +// `disabled` -- include emojis that have been disabled. +// +// `enabled` -- include emojis that are enabled. +// +// `shortcode:[shortcode]` -- show only emojis with the given shortcode, eg `?filter=shortcode:blob_cat_uwu` will show only emojis with the shortcode `blob_cat_uwu` (case sensitive). +// +// If neither `disabled` or `enabled` are provided, both disabled and enabled emojis will be shown. +// +// If no filter query string is provided, the default `domain:all` will be used, which will show all emojis from all domains. +// in: query +// required: false +// default: "domain:all" +// - +// name: limit +// type: integer +// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis). +// default: 50 +// in: query +// - +// name: max_shortcode_domain +// type: string +// description: >- +// Return only emojis with `[shortcode]@[domain]` *LOWER* (alphabetically) than given `[shortcode]@[domain]`. +// For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with +// `[shortcode]@[domain]`s like `car@example.org`, `debian@aaa.com`, `test@` (local emoji), etc. +// +// Emoji with the given `[shortcode]@[domain]` will not be included in the result set. +// in: query +// - +// name: min_shortcode_domain +// type: string +// description: >- +// Return only emojis with `[shortcode]@[domain]` *HIGHER* (alphabetically) than given `[shortcode]@[domain]`. +// For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with +// `[shortcode]@[domain]`s like `arse@test.com`, `0101_binary@hackers.net`, `bee@` (local emoji), etc. +// +// Emoji with the given `[shortcode]@[domain]` will not be included in the result set. +// in: query +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// description: An array of emojis, arranged alphabetically by shortcode and domain. +// schema: +// type: array +// items: +// "$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) EmojisGETHandler(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 + } + + maxShortcodeDomain := c.Query(MaxShortcodeDomainKey) + minShortcodeDomain := c.Query(MinShortcodeDomainKey) + + limit := 50 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + if limit < 0 { + limit = 0 + } + + var domain string + var includeDisabled bool + var includeEnabled bool + var shortcode string + if filterParam := c.Query(FilterQueryKey); filterParam != "" { + filters := strings.Split(filterParam, ",") + for _, filter := range filters { + lower := strings.ToLower(filter) + switch { + case strings.HasPrefix(lower, "domain:"): + domain = strings.TrimPrefix(lower, "domain:") + case lower == "disabled": + includeDisabled = true + case lower == "enabled": + includeEnabled = true + case strings.HasPrefix(lower, "shortcode:"): + shortcode = strings.Trim(filter[10:], ":") // remove any errant ":" + default: + err := fmt.Errorf("filter %s not recognized; accepted values are 'domain:[domain]', 'disabled', 'enabled', 'shortcode:[shortcode]'", filter) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + } + } + + if domain == "" { + // default is to show all domains + domain = db.EmojiAllDomains + } else if domain == "local" || domain == config.GetHost() || domain == config.GetAccountDomain() { + // pass empty string for local domain + domain = "" + } + + // normalize filters + if !includeDisabled && !includeEnabled { + // include both if neither specified + includeDisabled = true + includeEnabled = true + } + + resp, errWithCode := m.processor.AdminEmojisGet(c.Request.Context(), authed, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/admin/emojiget_test.go b/internal/api/client/admin/emojiget_test.go new file mode 100644 index 000000000..bba5561af --- /dev/null +++ b/internal/api/client/admin/emojiget_test.go @@ -0,0 +1,114 @@ +/* + 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 ( + "encoding/json" + "io" + "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" +) + +type EmojiGetTestSuite struct { + AdminStandardTestSuite +} + +func (suite *EmojiGetTestSuite) TestEmojiGet() { + recorder := httptest.NewRecorder() + + path := admin.EmojiPath + "?filter=domain:all&limit=1" + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.EmojisGETHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + + apiEmojis := []*apimodel.AdminEmoji{} + if err := json.Unmarshal(b, &apiEmojis); err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(apiEmojis, 1) + suite.Equal("rainbow", apiEmojis[0].Shortcode) + suite.Equal("", apiEmojis[0].Domain) + + suite.Equal(`; rel="next", ; rel="prev"`, recorder.Header().Get("link")) +} + +func (suite *EmojiGetTestSuite) TestEmojiGet2() { + recorder := httptest.NewRecorder() + + path := admin.EmojiPath + "?filter=domain:all&limit=1&max_shortcode_domain=rainbow@" + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.EmojisGETHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + + apiEmojis := []*apimodel.AdminEmoji{} + if err := json.Unmarshal(b, &apiEmojis); err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(apiEmojis, 1) + suite.Equal("yell", apiEmojis[0].Shortcode) + suite.Equal("fossbros-anonymous.io", apiEmojis[0].Domain) + + suite.Equal(`; rel="next", ; rel="prev"`, recorder.Header().Get("link")) +} + +func (suite *EmojiGetTestSuite) TestEmojiGet3() { + recorder := httptest.NewRecorder() + + path := admin.EmojiPath + "?filter=domain:all&limit=1&min_shortcode_domain=yell@fossbros-anonymous.io" + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.EmojisGETHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + suite.NoError(err) + suite.NotNil(b) + + apiEmojis := []*apimodel.AdminEmoji{} + if err := json.Unmarshal(b, &apiEmojis); err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(apiEmojis, 1) + suite.Equal("rainbow", apiEmojis[0].Shortcode) + suite.Equal("", apiEmojis[0].Domain) + + suite.Equal(`; rel="next", ; rel="prev"`, recorder.Header().Get("link")) +} + +func TestEmojiGetTestSuite(t *testing.T) { + suite.Run(t, &EmojiGetTestSuite{}) +} diff --git a/internal/api/client/admin/mediacleanup_test.go b/internal/api/client/admin/mediacleanup_test.go index 039bb7598..d2713084e 100644 --- a/internal/api/client/admin/mediacleanup_test.go +++ b/internal/api/client/admin/mediacleanup_test.go @@ -40,7 +40,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanup() { // set up the request recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 1}"), admin.EmojiPath, "application/json") + ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 1}"), admin.MediaCleanupPath, "application/json") // call the handler suite.adminModule.MediaCleanupPOSTHandler(ctx) @@ -66,7 +66,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNoArg() { // set up the request recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte("{}"), admin.EmojiPath, "application/json") + ctx := suite.newContext(recorder, http.MethodPost, []byte("{}"), admin.MediaCleanupPath, "application/json") // call the handler suite.adminModule.MediaCleanupPOSTHandler(ctx) @@ -90,7 +90,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNotOldEnough() { // set up the request recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 10000}"), admin.EmojiPath, "application/json") + ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 10000}"), admin.MediaCleanupPath, "application/json") // call the handler suite.adminModule.MediaCleanupPOSTHandler(ctx) diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index 023ba42b1..e5c956e0d 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -80,6 +80,35 @@ type AdminReportInfo struct { Statuses []Status `json:"statuses"` } +// AdminEmoji models the admin view of a custom emoji. +// +// swagger:model adminEmoji +type AdminEmoji struct { + Emoji + // The ID of the emoji. + // example: 01GEM7SFDZ7GZNRXFVZ3X4E4N1 + ID string `json:"id"` + // True if this emoji has been disabled by an admin action. + // example: false + Disabled bool `json:"disabled"` + // The domain from which the emoji originated. Only defined for remote domains, otherwise key will not be set. + // + // example: example.org + Domain string `json:"domain,omitempty"` + // Time when the emoji image was last updated. + // example: 2022-10-05T09:21:26.419Z + UpdatedAt string `json:"updated_at"` + // The total file size taken up by the emoji in bytes, including static and animated versions. + // example: 69420 + TotalFileSize int `json:"total_file_size"` + // The MIME content type of the emoji. + // example: image/png + ContentType string `json:"content_type"` + // The ActivityPub URI of the emoji. + // example: https://example.org/emojis/016T5Q3SQKBT337DAKVSKNXXW1 + URI string `json:"uri"` +} + // AdminAccountActionRequest models the admin view of an account's details. // // swagger:ignore diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go index e781e2f00..640e354c4 100644 --- a/internal/db/bundb/emoji.go +++ b/internal/db/bundb/emoji.go @@ -27,6 +27,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" ) type emojiDB struct { @@ -49,7 +50,124 @@ func (e *emojiDB) PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) db.Error return nil } -func (e *emojiDB) GetCustomEmojis(ctx context.Context) ([]*gtsmodel.Emoji, db.Error) { +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{} + + subQuery := e.conn. + NewSelect(). + ColumnExpr("? AS ?", bun.Ident("emoji.id"), bun.Ident("emoji_ids")) + + // To ensure consistent ordering and make paging possible, we sort not by shortcode + // but by [shortcode]@[domain]. Because sqlite and postgres have different syntax + // for concatenation, that means we need to switch here. Depending on which driver + // is in use, query will look something like this (sqlite): + // + // SELECT + // "emoji"."id" AS "emoji_ids", + // lower("emoji"."shortcode" || '@' || COALESCE("emoji"."domain", '')) AS "shortcode_domain" + // FROM + // "emojis" AS "emoji" + // ORDER BY + // "shortcode_domain" ASC + // + // Or like this (postgres): + // + // SELECT + // "emoji"."id" AS "emoji_ids", + // LOWER(CONCAT("emoji"."shortcode", '@', COALESCE("emoji"."domain", ''))) AS "shortcode_domain" + // FROM + // "emojis" AS "emoji" + // ORDER BY + // "shortcode_domain" ASC + switch e.conn.Dialect().Name() { + case dialect.SQLite: + subQuery = subQuery.ColumnExpr("LOWER(? || ? || COALESCE(?, ?)) AS ?", bun.Ident("emoji.shortcode"), "@", bun.Ident("emoji.domain"), "", bun.Ident("shortcode_domain")) + case dialect.PG: + subQuery = subQuery.ColumnExpr("LOWER(CONCAT(?, ?, COALESCE(?, ?))) AS ?", bun.Ident("emoji.shortcode"), "@", bun.Ident("emoji.domain"), "", bun.Ident("shortcode_domain")) + default: + panic("db conn was neither pg not sqlite") + } + + subQuery = subQuery.TableExpr("? AS ?", bun.Ident("emojis"), bun.Ident("emoji")) + + if domain == "" { + subQuery = subQuery.Where("? IS NULL", bun.Ident("emoji.domain")) + } else if domain != db.EmojiAllDomains { + subQuery = subQuery.Where("? = ?", bun.Ident("emoji.domain"), domain) + } + + switch { + case includeDisabled && !includeEnabled: + // show only disabled emojis + subQuery = subQuery.Where("? = ?", bun.Ident("emoji.disabled"), true) + case includeEnabled && !includeDisabled: + // show only enabled emojis + subQuery = subQuery.Where("? = ?", bun.Ident("emoji.disabled"), false) + default: + // show emojis regardless of emoji.disabled value + } + + if shortcode != "" { + subQuery = subQuery.Where("LOWER(?) = LOWER(?)", bun.Ident("emoji.shortcode"), shortcode) + } + + // assume we want to sort ASC (a-z) unless informed otherwise + order := "ASC" + + if maxShortcodeDomain != "" { + subQuery = subQuery.Where("? > LOWER(?)", bun.Ident("shortcode_domain"), maxShortcodeDomain) + } + + if minShortcodeDomain != "" { + subQuery = subQuery.Where("? < LOWER(?)", bun.Ident("shortcode_domain"), minShortcodeDomain) + // if we have a minShortcodeDomain we're paging upwards/backwards + order = "DESC" + } + + subQuery = subQuery.Order("shortcode_domain " + order) + + if limit > 0 { + subQuery = subQuery.Limit(limit) + } + + // Wrap the subQuery in a query, since we don't need to select the shortcode_domain column. + // + // The final query will come out looking something like... + // + // SELECT + // "subquery"."emoji_ids" + // FROM ( + // SELECT + // "emoji"."id" AS "emoji_ids", + // LOWER("emoji"."shortcode" || '@' || COALESCE("emoji"."domain", '')) AS "shortcode_domain" + // FROM + // "emojis" AS "emoji" + // ORDER BY + // "shortcode_domain" ASC + // ) AS "subquery" + if err := e.conn. + NewSelect(). + Column("subquery.emoji_ids"). + TableExpr("(?) AS ?", subQuery, bun.Ident("subquery")). + Scan(ctx, &emojiIDs); err != nil { + return nil, e.conn.ProcessError(err) + } + + if order == "DESC" { + // Reverse the slice order so the caller still + // gets emojis in expected a-z alphabetical order. + // + // See https://github.com/golang/go/wiki/SliceTricks#reversing + for i := len(emojiIDs)/2 - 1; i >= 0; i-- { + opp := len(emojiIDs) - 1 - i + emojiIDs[i], emojiIDs[opp] = emojiIDs[opp], emojiIDs[i] + } + } + + return e.emojisFromIDs(ctx, emojiIDs) +} + +func (e *emojiDB) GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, db.Error) { emojiIDs := []string{} q := e.conn. diff --git a/internal/db/bundb/emoji_test.go b/internal/db/bundb/emoji_test.go index 0a1546d91..3c61fb620 100644 --- a/internal/db/bundb/emoji_test.go +++ b/internal/db/bundb/emoji_test.go @@ -23,20 +23,108 @@ import ( "testing" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" ) type EmojiTestSuite struct { BunDBStandardTestSuite } -func (suite *EmojiTestSuite) TestGetCustomEmojis() { - emojis, err := suite.db.GetCustomEmojis(context.Background()) +func (suite *EmojiTestSuite) TestGetUseableEmojis() { + emojis, err := suite.db.GetUseableEmojis(context.Background()) suite.NoError(err) suite.Equal(1, len(emojis)) suite.Equal("rainbow", emojis[0].Shortcode) } +func (suite *EmojiTestSuite) TestGetAllEmojis() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "", 0) + + suite.NoError(err) + suite.Equal(2, len(emojis)) + suite.Equal("rainbow", emojis[0].Shortcode) + suite.Equal("yell", emojis[1].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetAllEmojisLimit1() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "", 1) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("rainbow", emojis[0].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetAllEmojisMaxID() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "rainbow@", "", 0) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("yell", emojis[0].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetAllEmojisMinID() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "yell@fossbros-anonymous.io", 0) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("rainbow", emojis[0].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetAllDisabledEmojis() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, false, "", "", "", 0) + + suite.ErrorIs(err, db.ErrNoEntries) + suite.Equal(0, len(emojis)) +} + +func (suite *EmojiTestSuite) TestGetAllEnabledEmojis() { + emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, false, true, "", "", "", 0) + + suite.NoError(err) + suite.Equal(2, len(emojis)) + suite.Equal("rainbow", emojis[0].Shortcode) + suite.Equal("yell", emojis[1].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetLocalEnabledEmojis() { + emojis, err := suite.db.GetEmojis(context.Background(), "", false, true, "", "", "", 0) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("rainbow", emojis[0].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetLocalDisabledEmojis() { + emojis, err := suite.db.GetEmojis(context.Background(), "", true, false, "", "", "", 0) + + suite.ErrorIs(err, db.ErrNoEntries) + suite.Equal(0, len(emojis)) +} + +func (suite *EmojiTestSuite) TestGetAllEmojisFromDomain() { + emojis, err := suite.db.GetEmojis(context.Background(), "peepee.poopoo", true, true, "", "", "", 0) + + suite.ErrorIs(err, db.ErrNoEntries) + suite.Equal(0, len(emojis)) +} + +func (suite *EmojiTestSuite) TestGetAllEmojisFromDomain2() { + emojis, err := suite.db.GetEmojis(context.Background(), "fossbros-anonymous.io", true, true, "", "", "", 0) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("yell", emojis[0].Shortcode) +} + +func (suite *EmojiTestSuite) TestGetSpecificEmojisFromDomain2() { + emojis, err := suite.db.GetEmojis(context.Background(), "fossbros-anonymous.io", true, true, "yell", "", "", 0) + + suite.NoError(err) + suite.Equal(1, len(emojis)) + suite.Equal("yell", emojis[0].Shortcode) +} + func TestEmojiTestSuite(t *testing.T) { suite.Run(t, new(EmojiTestSuite)) } diff --git a/internal/db/emoji.go b/internal/db/emoji.go index 374fd7b12..4316a43ef 100644 --- a/internal/db/emoji.go +++ b/internal/db/emoji.go @@ -24,12 +24,18 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) +// EmojiAllDomains can be used as the `domain` value in a GetEmojis +// query to indicate that emojis from all domains should be returned. +const EmojiAllDomains string = "all" + // Emoji contains functions for getting emoji in the database. type Emoji interface { // PutEmoji puts one emoji in the database. PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) Error - // GetCustomEmojis gets all custom emoji for the instance - GetCustomEmojis(ctx context.Context) ([]*gtsmodel.Emoji, 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. + GetEmojis(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, Error) // GetEmojiByID gets a specific emoji by its database ID. GetEmojiByID(ctx context.Context, id string) (*gtsmodel.Emoji, Error) // GetEmojiByShortcodeDomain gets an emoji based on its shortcode and domain. diff --git a/internal/processing/admin.go b/internal/processing/admin.go index cbbea05b1..59a4f8f1b 100644 --- a/internal/processing/admin.go +++ b/internal/processing/admin.go @@ -34,6 +34,10 @@ func (p *processor) AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, fo return p.adminProcessor.EmojiCreate(ctx, authed.Account, authed.User, form) } +func (p *processor) 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) { + return p.adminProcessor.EmojisGet(ctx, authed.Account, authed.User, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit) +} + 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 c528f0fb8..0de165fb9 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -41,6 +41,7 @@ type Processor interface { DomainBlockDelete(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode) AccountAction(ctx context.Context, account *gtsmodel.Account, form *apimodel.AdminAccountActionRequest) gtserror.WithCode 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) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode } diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/createemoji.go similarity index 100% rename from internal/processing/admin/emoji.go rename to internal/processing/admin/createemoji.go diff --git a/internal/processing/admin/getemojis.go b/internal/processing/admin/getemojis.go new file mode 100644 index 000000000..d44b4d250 --- /dev/null +++ b/internal/processing/admin/getemojis.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 + +import ( + "context" + "errors" + "fmt" + "strings" + + 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/util" +) + +func (p *processor) 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) { + if !*user.Admin { + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin") + } + + emojis, err := p.db.GetEmojis(ctx, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := fmt.Errorf("EmojisGet: db error: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(emojis) + if count == 0 { + return util.EmptyPageableResponse(), nil + } + + items := make([]interface{}, 0, count) + for _, emoji := range emojis { + adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, emoji) + if err != nil { + err := fmt.Errorf("EmojisGet: error converting emoji to admin model emoji: %s", err) + return nil, gtserror.NewErrorInternalError(err) + } + items = append(items, adminEmoji) + } + + filterBuilder := strings.Builder{} + filterBuilder.WriteString("filter=") + + switch domain { + case "", "local": + filterBuilder.WriteString("domain:local") + case db.EmojiAllDomains: + filterBuilder.WriteString("domain:all") + default: + filterBuilder.WriteString("domain:") + filterBuilder.WriteString(domain) + } + + if includeDisabled != includeEnabled { + if includeDisabled { + filterBuilder.WriteString(",disabled") + } + if includeEnabled { + filterBuilder.WriteString(",enabled") + } + } + + if shortcode != "" { + filterBuilder.WriteString(",shortcode:") + filterBuilder.WriteString(shortcode) + } + + return util.PackagePageableResponse(util.PageableResponseParams{ + Items: items, + Path: "api/v1/admin/custom_emojis", + NextMaxIDKey: "max_shortcode_domain", + NextMaxIDValue: shortcodeDomain(emojis[count-1]), + PrevMinIDKey: "min_shortcode_domain", + PrevMinIDValue: shortcodeDomain(emojis[0]), + Limit: limit, + ExtraQueryParams: []string{filterBuilder.String()}, + }) +} + +func shortcodeDomain(emoji *gtsmodel.Emoji) string { + return emoji.Shortcode + "@" + emoji.Domain +} diff --git a/internal/processing/media/getemoji.go b/internal/processing/media/getemoji.go index ee33c25eb..83a75eb66 100644 --- a/internal/processing/media/getemoji.go +++ b/internal/processing/media/getemoji.go @@ -29,7 +29,7 @@ import ( ) func (p *processor) GetCustomEmojis(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode) { - emojis, err := p.db.GetCustomEmojis(ctx) + emojis, err := p.db.GetUseableEmojis(ctx) if err != nil { if err != db.ErrNoEntries { return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error retrieving custom emojis: %s", err)) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index c76b1623b..b616511ea 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -112,6 +112,8 @@ type Processor interface { AdminAccountAction(ctx context.Context, authed *oauth.Auth, form *apimodel.AdminAccountActionRequest) gtserror.WithCode // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode) + // AdminEmojisGet allows admins to view emojis based on various filters. + 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) // 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 b1a771458..1ad7264ed 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -67,6 +67,8 @@ type TypeConverter interface { MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (model.Mention, error) // EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API. 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) // 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 09bd5fc7d..7b4c3e8cc 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -363,6 +363,24 @@ func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (mod }, nil } +func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error) { + emoji, err := c.EmojiToAPIEmoji(ctx, e) + if err != nil { + return nil, err + } + + return &model.AdminEmoji{ + Emoji: emoji, + ID: e.ID, + Disabled: *e.Disabled, + Domain: e.Domain, + UpdatedAt: util.FormatISO8601(e.UpdatedAt), + TotalFileSize: e.ImageFileSize + e.ImageStaticFileSize, + ContentType: e.ImageContentType, + URI: e.URI, + }, 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 9dd8ed4e3..f2514e719 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -141,6 +141,36 @@ func (suite *InternalToFrontendTestSuite) TestInstanceToFrontendWithAdminAccount suite.Equal(`{"uri":"https://example.org","title":"example instance","description":"a much longer description","short_description":"a little description","email":"someone@example.org","version":"software-from-hell 0.666","registrations":false,"approval_required":false,"invites_enabled":false,"thumbnail":"","contact_account":{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","username":"some_user","acct":"some_user@example.org","display_name":"some user","locked":true,"bot":false,"created_at":"2020-08-10T12:13:28.000Z","note":"i'm a real son of a gun","url":"http://example.org/@some_user","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":0,"last_status_at":"","emojis":[],"fields":[]},"max_toot_chars":0}`, string(b)) } +func (suite *InternalToFrontendTestSuite) TestEmojiToFrontend() { + emoji, err := suite.typeconverter.EmojiToAPIEmoji(context.Background(), suite.testEmojis["rainbow"]) + suite.NoError(err) + + 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)) +} + +func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin1() { + emoji, err := suite.typeconverter.EmojiToAdminAPIEmoji(context.Background(), suite.testEmojis["rainbow"]) + suite.NoError(err) + + 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)) +} + +func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin2() { + emoji, err := suite.typeconverter.EmojiToAdminAPIEmoji(context.Background(), suite.testEmojis["yell"]) + suite.NoError(err) + + b, err := json.Marshal(emoji) + suite.NoError(err) + + suite.Equal(`{"shortcode":"yell","url":"http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/original/01GD5KP5CQEE1R3X43Y1EHS2CW.png","static_url":"http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/static/01GD5KP5CQEE1R3X43Y1EHS2CW.png","visible_in_picker":false,"id":"01GD5KP5CQEE1R3X43Y1EHS2CW","disabled":false,"domain":"fossbros-anonymous.io","updated_at":"2020-03-18T12:12:00.000Z","total_file_size":21697,"content_type":"image/png","uri":"http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW"}`, string(b)) +} + func TestInternalToFrontendTestSuite(t *testing.T) { suite.Run(t, new(InternalToFrontendTestSuite)) }