gotosocial/internal/processing/admin/emoji.go
Markus Unterwaditzer a48cce82b9
[chore] Upgrade golangci-lint, ignore existing int overflow warnings (#3420)
* [chore] Bump tooling versions, bump go -> v1.23.0

* undo silly change

* sign

* bump go version in go.mod

* allow overflow in imaging

* goreleaser deprecation notices

* [chore] Upgrade golangci-lint, ignore existing int overflow warnings

There is a new lint for unchecked int casts. Integer overflows are bad,
but the old code that triggers this lint seems to be perfectly fine.
Instead of disabling the lint entirely for new code as well, grandfather
in existing code.

* fix golangci-lint documentation link

* revert unrelated changes

* revert another unrelated change

* get rid of remaining nolint:gosec

* swagger updates

* apply review feedback

* fix wrong formatting specifier thing

* fix the linter for real

---------

Co-authored-by: tobi <tobi.smethurst@protonmail.com>
2024-10-16 14:13:58 +02:00

636 lines
18 KiB
Go

// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// 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 <http://www.gnu.org/licenses/>.
package admin
import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"strings"
"codeberg.org/gruf/go-iotools"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"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/util"
)
// EmojiCreate creates a custom emoji on this instance.
func (p *Processor) EmojiCreate(
ctx context.Context,
account *gtsmodel.Account,
form *apimodel.EmojiCreateRequest,
) (*apimodel.Emoji, gtserror.WithCode) {
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if form.Image.Size > maxszInt64 {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Open multipart file reader.
mpfile, err := form.Image.Open()
if err != nil {
err := gtserror.Newf("error opening multipart file: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
data := func(context.Context) (io.ReadCloser, error) {
return rc, nil
}
// Attempt to create the new local emoji.
emoji, errWithCode := p.createEmoji(ctx,
form.Shortcode,
form.CategoryName,
data,
)
if errWithCode != nil {
return nil, errWithCode
}
apiEmoji, err := p.converter.EmojiToAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return &apiEmoji, nil
}
// EmojisGet returns an admin view of custom
// emojis, filtered with the given parameters.
func (p *Processor) EmojisGet(
ctx context.Context,
account *gtsmodel.Account,
domain string,
includeDisabled bool,
includeEnabled bool,
shortcode string,
maxShortcodeDomain string,
minShortcodeDomain string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) {
emojis, err := p.state.DB.GetEmojisBy(ctx,
domain,
includeDisabled,
includeEnabled,
shortcode,
maxShortcodeDomain,
minShortcodeDomain,
limit,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", 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.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji to admin model emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
items = append(items, adminEmoji)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "api/v1/admin/custom_emojis",
NextMaxIDKey: "max_shortcode_domain",
NextMaxIDValue: emojis[count-1].ShortcodeDomain(),
PrevMinIDKey: "min_shortcode_domain",
PrevMinIDValue: emojis[0].ShortcodeDomain(),
Limit: limit,
ExtraQueryParams: []string{
emojisGetFilterParams(
shortcode,
domain,
includeDisabled,
includeEnabled,
),
},
})
}
// EmojiGet returns the admin view of
// one custom emoji with the given id.
func (p *Processor) EmojiGet(
ctx context.Context,
account *gtsmodel.Account,
id string,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
emoji, err := p.state.DB.GetEmojiByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if emoji == nil {
err := gtserror.Newf("no emoji with id %s found in the db", id)
return nil, gtserror.NewErrorNotFound(err)
}
adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji to admin api emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return adminEmoji, nil
}
// EmojiDelete deletes one *local* emoji
// from the database, with the given id.
func (p *Processor) EmojiDelete(
ctx context.Context,
id string,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
emoji, err := p.state.DB.GetEmojiByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if emoji == nil {
err := gtserror.Newf("no emoji with id %s found in the db", id)
return nil, gtserror.NewErrorNotFound(err)
}
if !emoji.IsLocal() {
err := fmt.Errorf("emoji with id %s was not a local emoji, will not delete", id)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Convert to admin emoji before deletion,
// so we can return the deleted emoji.
adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji to admin api emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if err := p.state.DB.DeleteEmojiByID(ctx, id); err != nil {
err := gtserror.Newf("db error deleting emoji %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
return adminEmoji, nil
}
// EmojiUpdate updates one emoji with the
// given id, using the provided form parameters.
func (p *Processor) EmojiUpdate(
ctx context.Context,
emojiID string,
form *apimodel.EmojiUpdateRequest,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
// Get the emoji with given ID from the database.
emoji, err := p.state.DB.GetEmojiByID(ctx, emojiID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error fetching emoji from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Check found.
if emoji == nil {
const text = "emoji not found"
return nil, gtserror.NewErrorNotFound(errors.New(text), text)
}
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:
const text = "unrecognized emoji update action type"
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
}
// EmojiCategoriesGet returns all custom emoji
// categories that exist on this instance.
func (p *Processor) EmojiCategoriesGet(
ctx context.Context,
) ([]*apimodel.EmojiCategory, gtserror.WithCode) {
categories, err := p.state.DB.GetEmojiCategories(ctx)
if err != nil {
err := gtserror.Newf("db error getting emoji categories: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
apiCategories := make([]*apimodel.EmojiCategory, 0, len(categories))
for _, category := range categories {
apiCategory, err := p.converter.EmojiCategoryToAPIEmojiCategory(ctx, category)
if err != nil {
err := gtserror.Newf("error converting emoji category to api emoji category: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
apiCategories = append(apiCategories, apiCategory)
}
return apiCategories, nil
}
// emojiUpdateCopy copies and stores the given
// *remote* emoji as a *local* emoji, preserving
// the same image, and using the provided shortcode.
//
// The provided emoji model must correspond to an
// emoji already stored in the database + storage.
func (p *Processor) emojiUpdateCopy(
ctx context.Context,
target *gtsmodel.Emoji,
shortcode *string,
categoryName *string,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
if target.IsLocal() {
const text = "target emoji is not remote; cannot copy to local"
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Ensure target emoji is locally cached.
target, err := p.federator.RecacheEmoji(ctx,
target,
)
if err != nil {
err := gtserror.Newf("error recaching emoji %s: %w", target.ImageRemoteURL, err)
return nil, gtserror.NewErrorNotFound(err)
}
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt := int(maxsz) // #nosec G115 -- Already validated.
// Ensure target emoji image within size bounds.
if target.ImageFileSize > maxszInt {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Data function for copying just streams media
// out of storage into an additional location.
//
// This means that data for the copy persists even
// if the remote copied emoji gets deleted at some point.
data := func(ctx context.Context) (io.ReadCloser, error) {
rc, err := p.state.Storage.GetStream(ctx, target.ImagePath)
return rc, err
}
// Attempt to create the new local emoji.
emoji, errWithCode := p.createEmoji(ctx,
util.PtrOrZero(shortcode),
util.PtrOrZero(categoryName),
data,
)
if errWithCode != nil {
return nil, errWithCode
}
apiEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiEmoji, nil
}
// emojiUpdateDisable marks the given *remote*
// emoji as disabled by setting disabled = true.
//
// The provided emoji model must correspond to an
// emoji already stored in the database + storage.
func (p *Processor) emojiUpdateDisable(
ctx context.Context,
emoji *gtsmodel.Emoji,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
if emoji.IsLocal() {
err := fmt.Errorf("emoji %s is not a remote emoji, cannot disable it via this endpoint", emoji.ID)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Only bother with a db call
// if emoji not already disabled.
if !*emoji.Disabled {
emoji.Disabled = util.Ptr(true)
if err := p.state.DB.UpdateEmoji(ctx, emoji, "disabled"); err != nil {
err := gtserror.Newf("db error updating emoji %s: %w", emoji.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return adminEmoji, nil
}
// emojiUpdateModify modifies the given *local* emoji.
//
// Either one of image or category must be non-nil,
// otherwise there's nothing to modify. If category
// is non-nil and dereferences to an empty string,
// category will be cleared.
//
// The provided emoji model must correspond to an
// emoji already stored in the database + storage.
func (p *Processor) emojiUpdateModify(
ctx context.Context,
emoji *gtsmodel.Emoji,
image *multipart.FileHeader,
categoryName *string,
) (*apimodel.AdminEmoji, gtserror.WithCode) {
if !emoji.IsLocal() {
const text = "cannot modify remote emoji"
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Ensure there's actually something to update.
if image == nil && categoryName == nil {
const text = "no changes were provided"
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Check if we need to
// set a new category ID.
var newCategoryID *string
switch {
case categoryName == nil:
// No changes.
case *categoryName == "":
// Emoji category was unset.
newCategoryID = util.Ptr("")
emoji.CategoryID = ""
emoji.Category = nil
case *categoryName != "":
// A category was provided, get or create relevant emoji category.
category, errWithCode := p.mustGetEmojiCategory(ctx, *categoryName)
if errWithCode != nil {
return nil, errWithCode
}
// Update emoji category if
// it's different from before.
if category.ID != emoji.CategoryID {
newCategoryID = &category.ID
emoji.CategoryID = category.ID
emoji.Category = category
}
}
// Check whether any image changes were requested.
imageUpdated := (image != nil && image.Size > 0)
if !imageUpdated && newCategoryID != nil {
// Only updating category; only a single database update required.
if err := p.state.DB.UpdateEmoji(ctx, emoji, "category_id"); err != nil {
err := gtserror.Newf("error updating emoji in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
} else if imageUpdated {
var err error
// Updating image and maybe categoryID.
// We can do both at the same time :)
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if image.Size > maxszInt64 {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Open multipart file reader.
mpfile, err := image.Open()
if err != nil {
err := gtserror.Newf("error opening multipart file: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz)) // #nosec G115 -- Already validated.
data := func(context.Context) (io.ReadCloser, error) {
return rc, nil
}
// Include category ID
// update if necessary.
ai := media.AdditionalEmojiInfo{}
ai.CategoryID = newCategoryID
// Prepare emoji model for update+recache from new data.
processing, err := p.media.UpdateEmoji(ctx, emoji, data, ai)
if err != nil {
err := gtserror.Newf("error preparing recache: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Load to trigger update + write.
emoji, err = processing.Load(ctx)
if err != nil {
err := gtserror.Newf("error processing emoji %s: %w", emoji.Shortcode, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)
if err != nil {
err := gtserror.Newf("error converting emoji: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return adminEmoji, nil
}
// createEmoji will create a new local emoji
// with the given shortcode, attached category
// name (if any) and data source function.
func (p *Processor) createEmoji(
ctx context.Context,
shortcode string,
categoryName string,
data media.DataFunc,
) (
*gtsmodel.Emoji,
gtserror.WithCode,
) {
// Validate shortcode.
if shortcode == "" {
const text = "empty shortcode name"
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Look for an existing local emoji with shortcode to ensure this is new.
existing, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, shortcode, "")
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error fetching emoji from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} else if existing != nil {
const text = "emoji with shortcode already exists"
return nil, gtserror.NewErrorConflict(errors.New(text), text)
}
var categoryID *string
if categoryName != "" {
// A category was provided, get / create relevant emoji category.
category, errWithCode := p.mustGetEmojiCategory(ctx, categoryName)
if errWithCode != nil {
return nil, errWithCode
}
// Set category ID for emoji.
categoryID = &category.ID
}
// Store to instance storage.
return p.c.StoreLocalEmoji(
ctx,
shortcode,
data,
media.AdditionalEmojiInfo{
CategoryID: categoryID,
},
)
}
// mustGetEmojiCategory either gets an existing
// category with the given name from the database,
// or, if the category doesn't yet exist, it creates
// the category and then returns it.
func (p *Processor) mustGetEmojiCategory(
ctx context.Context,
name string,
) (
*gtsmodel.EmojiCategory,
gtserror.WithCode,
) {
// Look for an existing emoji category with name.
category, err := p.state.DB.GetEmojiCategoryByName(ctx, name)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error fetching emoji category from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if category != nil {
// We had it already.
return category, nil
}
// Create new ID.
id := id.NewULID()
// Prepare new category for insertion.
category = &gtsmodel.EmojiCategory{
ID: id,
Name: name,
}
// Insert new category into the database.
err = p.state.DB.PutEmojiCategory(ctx, category)
if err != nil {
err := gtserror.Newf("error inserting emoji category into db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return category, nil
}
// emojisGetFilterParams builds extra
// query parameters to return as part
// of an Emojis pageable response.
//
// The returned string will look like:
//
// "filter=domain:all,enabled,shortcode:example"
func emojisGetFilterParams(
shortcode string,
domain string,
includeDisabled bool,
includeEnabled bool,
) string {
var filterBuilder strings.Builder
filterBuilder.WriteString("filter=")
switch domain {
case "", "local":
// Local emojis only.
filterBuilder.WriteString("domain:local")
case db.EmojiAllDomains:
// Local or remote.
filterBuilder.WriteString("domain:all")
default:
// Specific domain only.
filterBuilder.WriteString("domain:" + domain)
}
if includeDisabled != includeEnabled {
if includeDisabled {
filterBuilder.WriteString(",disabled")
}
if includeEnabled {
filterBuilder.WriteString(",enabled")
}
}
if shortcode != "" {
// Specific shortcode only.
filterBuilder.WriteString(",shortcode:" + shortcode)
}
return filterBuilder.String()
}