diff options
Diffstat (limited to 'internal/processing')
-rw-r--r-- | internal/processing/account/account_test.go | 2 | ||||
-rw-r--r-- | internal/processing/account/update.go | 102 | ||||
-rw-r--r-- | internal/processing/admin/admin.go | 35 | ||||
-rw-r--r-- | internal/processing/admin/debug_apurl.go | 2 | ||||
-rw-r--r-- | internal/processing/admin/email.go | 2 | ||||
-rw-r--r-- | internal/processing/admin/emoji.go | 529 | ||||
-rw-r--r-- | internal/processing/admin/media.go | 4 | ||||
-rw-r--r-- | internal/processing/common/common.go | 4 | ||||
-rw-r--r-- | internal/processing/common/media.go | 98 | ||||
-rw-r--r-- | internal/processing/instance.go | 20 | ||||
-rw-r--r-- | internal/processing/media/create.go | 27 | ||||
-rw-r--r-- | internal/processing/media/getfile.go | 384 | ||||
-rw-r--r-- | internal/processing/media/media.go | 17 | ||||
-rw-r--r-- | internal/processing/media/media_test.go | 9 | ||||
-rw-r--r-- | internal/processing/polls/poll_test.go | 2 | ||||
-rw-r--r-- | internal/processing/processor.go | 6 | ||||
-rw-r--r-- | internal/processing/status/status_test.go | 2 |
17 files changed, 685 insertions, 560 deletions
diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 556f4d91f..8eec1f9dd 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -111,7 +111,7 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.tc, suite.federator, filter) + common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, filter) suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index ea6abed6e..61e88501f 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -19,10 +19,12 @@ package account import ( "context" + "errors" "fmt" "io" "mime/multipart" + "codeberg.org/gruf/go-bytesize" "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -203,9 +205,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.Avatar != nil && form.Avatar.Size != 0 { - avatarInfo, err := p.UpdateAvatar(ctx, form.Avatar, nil, account.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + avatarInfo, errWithCode := p.UpdateAvatar(ctx, + account, + form.Avatar, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo @@ -213,9 +219,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.Header != nil && form.Header.Size != 0 { - headerInfo, err := p.UpdateHeader(ctx, form.Header, nil, account.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + headerInfo, errWithCode := p.UpdateHeader(ctx, + account, + form.Header, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo @@ -316,35 +326,33 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form // for this to become the account's new avatar. func (p *Processor) UpdateAvatar( ctx context.Context, + account *gtsmodel.Account, avatar *multipart.FileHeader, description *string, - accountID string, -) (*gtsmodel.MediaAttachment, error) { - maxImageSize := config.GetMediaImageMaxSize() - if avatar.Size > int64(maxImageSize) { - return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", avatar.Size, maxImageSize) +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + max := config.GetMediaImageMaxSize() + if sz := bytesize.Size(avatar.Size); sz > max { + text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := avatar.Open() return f, avatar.Size, err } - // Process the media attachment and load it immediately. - media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ - Avatar: util.Ptr(true), - Description: description, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - return attachment, nil + // Write to instance storage. + return p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Avatar: util.Ptr(true), + Description: description, + }, + ) } // UpdateHeader does the dirty work of checking the header @@ -353,33 +361,31 @@ func (p *Processor) UpdateAvatar( // for this to become the account's new header. func (p *Processor) UpdateHeader( ctx context.Context, + account *gtsmodel.Account, header *multipart.FileHeader, description *string, - accountID string, -) (*gtsmodel.MediaAttachment, error) { - maxImageSize := config.GetMediaImageMaxSize() - if header.Size > int64(maxImageSize) { - return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", header.Size, maxImageSize) +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + max := config.GetMediaImageMaxSize() + if sz := bytesize.Size(header.Size); sz > max { + text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := header.Open() return f, header.Size, err } - // Process the media attachment and load it immediately. - media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ - Header: util.Ptr(true), - Description: description, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - return attachment, nil + // Write to instance storage. + return p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Header: util.Ptr(true), + Description: description, + }, + ) } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 3093b3e36..170298ca5 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -20,20 +20,26 @@ package admin import ( "github.com/superseriousbusiness/gotosocial/internal/cleaner" "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type Processor struct { - state *state.State - cleaner *cleaner.Cleaner - converter *typeutils.Converter - mediaManager *media.Manager - transportController transport.Controller - emailSender email.Sender + // common processor logic + c *common.Processor + + state *state.State + cleaner *cleaner.Cleaner + converter *typeutils.Converter + federator *federation.Federator + media *media.Manager + transport transport.Controller + email email.Sender // admin Actions currently // undergoing processing @@ -46,21 +52,24 @@ func (p *Processor) Actions() *Actions { // New returns a new admin processor. func New( + common *common.Processor, state *state.State, cleaner *cleaner.Cleaner, + federator *federation.Federator, converter *typeutils.Converter, mediaManager *media.Manager, transportController transport.Controller, emailSender email.Sender, ) Processor { return Processor{ - state: state, - cleaner: cleaner, - converter: converter, - mediaManager: mediaManager, - transportController: transportController, - emailSender: emailSender, - + c: common, + state: state, + cleaner: cleaner, + converter: converter, + federator: federator, + media: mediaManager, + transport: transportController, + email: emailSender, actions: &Actions{ r: make(map[string]*gtsmodel.AdminAction), state: state, diff --git a/internal/processing/admin/debug_apurl.go b/internal/processing/admin/debug_apurl.go index db3c60d0c..dbf337dc3 100644 --- a/internal/processing/admin/debug_apurl.go +++ b/internal/processing/admin/debug_apurl.go @@ -78,7 +78,7 @@ func (p *Processor) DebugAPUrl( } // All looks fine. Prepare the transport and (signed) GET request. - tsport, err := p.transportController.NewTransportForUsername(ctx, adminAcct.Username) + tsport, err := p.transport.NewTransportForUsername(ctx, adminAcct.Username) if err != nil { err = gtserror.Newf("error creating transport: %w", err) return nil, gtserror.NewErrorInternalError(err, err.Error()) diff --git a/internal/processing/admin/email.go b/internal/processing/admin/email.go index fda60754c..949be6e4b 100644 --- a/internal/processing/admin/email.go +++ b/internal/processing/admin/email.go @@ -55,7 +55,7 @@ func (p *Processor) EmailTest( InstanceName: instance.Title, } - if err := p.emailSender.SendTestEmail(toAddress, testData); err != nil { + if err := p.email.SendTestEmail(toAddress, testData); err != nil { if gtserror.IsSMTP(err) { // An error occurred during the SMTP part. // We should indicate this to the caller, as diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index dcdf77642..4d1b464d3 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -31,7 +31,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -41,64 +40,21 @@ func (p *Processor) EmojiCreate( account *gtsmodel.Account, form *apimodel.EmojiCreateRequest, ) (*apimodel.Emoji, gtserror.WithCode) { - // Ensure emoji with this shortcode - // doesn't already exist on the instance. - maybeExisting, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, form.Shortcode, "") - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("error checking existence of emoji with shortcode %s: %w", form.Shortcode, err) - return nil, gtserror.NewErrorInternalError(err) - } - - if maybeExisting != nil { - err := fmt.Errorf("emoji with shortcode %s already exists", form.Shortcode) - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - // Prepare data function for emoji processing - // (just read data from the submitted form). - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + // Simply read provided form data for emoji data source. + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := form.Image.Open() return f, form.Image.Size, err } - // If category was supplied on the form, - // ensure the category exists and provide - // it as additional info to emoji processing. - var ai *media.AdditionalEmojiInfo - if form.CategoryName != "" { - category, err := p.getOrCreateEmojiCategory(ctx, form.CategoryName) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - ai = &media.AdditionalEmojiInfo{ - CategoryID: &category.ID, - } - } - - // Generate new emoji ID and URI. - emojiID, err := id.NewRandomULID() - if err != nil { - err := gtserror.Newf("error creating id for new emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - emojiURI := uris.URIForEmoji(emojiID) - - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, form.Shortcode, emojiID, emojiURI, ai, false, + // Attempt to create the new local emoji. + emoji, errWithCode := p.createEmoji(ctx, + form.Shortcode, + form.CategoryName, + data, ) - if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Complete processing immediately. - emoji, err := processingEmoji.LoadEmoji(ctx) - if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if errWithCode != nil { + return nil, errWithCode } apiEmoji, err := p.converter.EmojiToAPIEmoji(ctx, emoji) @@ -110,53 +66,6 @@ func (p *Processor) EmojiCreate( return &apiEmoji, 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() -} - // EmojisGet returns an admin view of custom // emojis, filtered with the given parameters. func (p *Processor) EmojisGet( @@ -287,21 +196,24 @@ func (p *Processor) EmojiDelete( // given id, using the provided form parameters. func (p *Processor) EmojiUpdate( ctx context.Context, - id string, + emojiID string, form *apimodel.EmojiUpdateRequest, ) (*apimodel.AdminEmoji, gtserror.WithCode) { - emoji, err := p.state.DB.GetEmojiByID(ctx, id) + + // 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("db error: %w", err) + err := gtserror.Newf("error fetching emoji from db: %w", err) return nil, gtserror.NewErrorInternalError(err) } + // Check found. if emoji == nil { - err := gtserror.Newf("no emoji with id %s found in the db", id) - return nil, gtserror.NewErrorNotFound(err) + const text = "emoji not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) } - switch t := form.Type; t { + switch form.Type { case apimodel.EmojiUpdateCopy: return p.emojiUpdateCopy(ctx, emoji, form.Shortcode, form.CategoryName) @@ -313,8 +225,8 @@ func (p *Processor) EmojiUpdate( return p.emojiUpdateModify(ctx, emoji, form.Image, form.CategoryName) default: - err := fmt.Errorf("unrecognized emoji action type %s", t) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + const text = "unrecognized emoji update action type" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } } @@ -342,56 +254,6 @@ func (p *Processor) EmojiCategoriesGet( return apiCategories, nil } -/* - UTIL FUNCTIONS -*/ - -// getOrCreateEmojiCategory 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) getOrCreateEmojiCategory( - ctx context.Context, - name string, -) (*gtsmodel.EmojiCategory, error) { - category, err := p.state.DB.GetEmojiCategoryByName(ctx, name) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.Newf( - "database error trying get emoji category %s: %w", - name, err, - ) - } - - if category != nil { - // We had it already. - return category, nil - } - - // We don't have the category yet, - // create it with the given name. - categoryID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.Newf( - "error generating id for new emoji category %s: %w", - name, err, - ) - } - - category = >smodel.EmojiCategory{ - ID: categoryID, - Name: name, - } - - if err := p.state.DB.PutEmojiCategory(ctx, category); err != nil { - return nil, gtserror.Newf( - "db error putting new emoji category %s: %w", - name, err, - ) - } - - return category, nil -} - // emojiUpdateCopy copies and stores the given // *remote* emoji as a *local* emoji, preserving // the same image, and using the provided shortcode. @@ -400,99 +262,56 @@ func (p *Processor) getOrCreateEmojiCategory( // emoji already stored in the database + storage. func (p *Processor) emojiUpdateCopy( ctx context.Context, - targetEmoji *gtsmodel.Emoji, + target *gtsmodel.Emoji, shortcode *string, - category *string, + categoryName *string, ) (*apimodel.AdminEmoji, gtserror.WithCode) { - if targetEmoji.IsLocal() { - err := fmt.Errorf("emoji %s is not a remote emoji, cannot copy it to local", targetEmoji.ID) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - if shortcode == nil { - err := errors.New("no shortcode provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + if target.IsLocal() { + const text = "target emoji is not remote; cannot copy to local" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - sc := *shortcode - if sc == "" { - err := errors.New("empty shortcode provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Ensure we don't already have an emoji - // stored locally with this shortcode. - maybeExisting, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, sc, "") - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error checking for emoji with shortcode %s: %w", sc, err) - return nil, gtserror.NewErrorInternalError(err) - } + // Ensure target emoji is locally cached. + target, err := p.federator.RefreshEmoji( + ctx, + target, - if maybeExisting != nil { - err := fmt.Errorf("emoji with shortcode %s already exists on this instance", sc) - return nil, gtserror.NewErrorConflict(err, err.Error()) + // no changes we want to make. + media.AdditionalEmojiInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching emoji %s: %w", target.ImageRemoteURL, err) + return nil, gtserror.NewErrorNotFound(err) } - // We don't have an emoji with this - // shortcode yet! Prepare to create it. - // 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, int64, error) { - rc, err := p.state.Storage.GetStream(ctx, targetEmoji.ImagePath) - return rc, int64(targetEmoji.ImageFileSize), err - } - - // Generate new emoji ID and URI. - emojiID, err := id.NewRandomULID() - if err != nil { - err := gtserror.Newf("error creating id for new emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - emojiURI := uris.URIForEmoji(emojiID) - - // If category was supplied, ensure the - // category exists and provide it as - // additional info to emoji processing. - var ai *media.AdditionalEmojiInfo - if category != nil && *category != "" { - category, err := p.getOrCreateEmojiCategory(ctx, *category) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - ai = &media.AdditionalEmojiInfo{ - CategoryID: &category.ID, - } + rc, err := p.state.Storage.GetStream(ctx, target.ImagePath) + return rc, int64(target.ImageFileSize), err } - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, sc, emojiID, emojiURI, ai, false, + // Attempt to create the new local emoji. + emoji, errWithCode := p.createEmoji(ctx, + util.PtrValueOr(shortcode, ""), + util.PtrValueOr(categoryName, ""), + data, ) - if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Complete processing immediately. - newEmoji, err := processingEmoji.LoadEmoji(ctx) - if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if errWithCode != nil { + return nil, errWithCode } - adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, newEmoji) + apiEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji) if err != nil { - err := gtserror.Newf("error converting emoji %s to admin emoji: %w", newEmoji.ID, err) + err := gtserror.Newf("error converting emoji: %w", err) return nil, gtserror.NewErrorInternalError(err) } - return adminEmoji, nil + return apiEmoji, nil } // emojiUpdateDisable marks the given *remote* @@ -521,7 +340,7 @@ func (p *Processor) emojiUpdateDisable( adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji) if err != nil { - err := gtserror.Newf("error converting emoji %s to admin emoji: %w", emoji.ID, err) + err := gtserror.Newf("error converting emoji: %w", err) return nil, gtserror.NewErrorInternalError(err) } @@ -541,104 +360,222 @@ func (p *Processor) emojiUpdateModify( ctx context.Context, emoji *gtsmodel.Emoji, image *multipart.FileHeader, - category *string, + categoryName *string, ) (*apimodel.AdminEmoji, gtserror.WithCode) { if !emoji.IsLocal() { - err := fmt.Errorf("emoji %s is not a local emoji, cannot update it via this endpoint", emoji.ID) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + const text = "cannot modify remote emoji" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } // Ensure there's actually something to update. - if image == nil && category == nil { - err := errors.New("neither new category nor new image set, cannot update") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + if image == nil && categoryName == nil { + const text = "no changes were provided" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - // Only update category - // if it's changed. - var ( - newCategory *gtsmodel.EmojiCategory - newCategoryID string - updateCategoryID bool - ) - - if category != nil { - catName := *category - if catName != "" { - // Set new category. - var err error - newCategory, err = p.getOrCreateEmojiCategory(ctx, catName) - if err != nil { - err := gtserror.Newf("error getting or creating category: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if categoryName != nil { + if *categoryName != "" { + // A category was provided, get / create relevant emoji category. + category, errWithCode := p.mustGetEmojiCategory(ctx, *categoryName) + if errWithCode != nil { + return nil, errWithCode } - newCategoryID = newCategory.ID + if category.ID == emoji.CategoryID { + // There was no change, + // indicate this by unsetting + // the category name pointer. + categoryName = nil + } else { + // Update emoji category. + emoji.CategoryID = category.ID + emoji.Category = category + } } else { - // Clear existing category. - newCategoryID = "" + // Emoji category was unset. + emoji.CategoryID = "" + emoji.Category = nil } - - updateCategoryID = emoji.CategoryID != newCategoryID } - // Only update image - // if one is provided. - var updateImage bool - if image != nil && image.Size != 0 { - updateImage = true - } + // Check whether any image changes were requested. + imageUpdated := (image != nil && image.Size > 0) - if updateCategoryID && !updateImage { - // Only updating category; we only - // need to do a db update for this. - emoji.CategoryID = newCategoryID - emoji.Category = newCategory + if !imageUpdated && categoryName != 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("db error updating emoji %s: %w", emoji.ID, err) + err := gtserror.Newf("error updating emoji in db: %w", err) return nil, gtserror.NewErrorInternalError(err) } - } else if updateImage { + } else if imageUpdated { + var err error + // Updating image and maybe categoryID. // We can do both at the same time :) - // Set data function to provided image. - data := func(ctx context.Context) (io.ReadCloser, int64, error) { - i, err := image.Open() - return i, image.Size, err + // Simply read provided form data for emoji data source. + data := func(_ context.Context) (io.ReadCloser, int64, error) { + f, err := image.Open() + return f, image.Size, err } - // If necessary, include - // update to categoryID too. - var ai *media.AdditionalEmojiInfo - if updateCategoryID { - ai = &media.AdditionalEmojiInfo{ - CategoryID: &newCategoryID, - } - } - - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, emoji.Shortcode, emoji.ID, emoji.URI, ai, false, - ) - if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } + // Prepare emoji model for recache from new data. + processing := p.media.RecacheEmoji(emoji, data) - // Replace emoji ptr with newly-processed version. - emoji, err = processingEmoji.LoadEmoji(ctx) + // Load to trigger update + write. + emoji, err = processing.Load(ctx) if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) + 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 %s to admin emoji: %w", emoji.ID, err) + 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 = >smodel.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() +} diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go index 13dcb7d28..edbcbe349 100644 --- a/internal/processing/admin/media.go +++ b/internal/processing/admin/media.go @@ -28,7 +28,7 @@ import ( // MediaRefetch forces a refetch of remote emojis. func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode { - transport, err := p.transportController.NewTransportForUsername(ctx, requestingAccount.Username) + transport, err := p.transport.NewTransportForUsername(ctx, requestingAccount.Username) if err != nil { err = fmt.Errorf("error getting transport for user %s during media refetch request: %w", requestingAccount.Username, err) return gtserror.NewErrorInternalError(err) @@ -36,7 +36,7 @@ func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmode go func() { log.Info(ctx, "starting emoji refetch") - refetched, err := p.mediaManager.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia) + refetched, err := p.media.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia) if err != nil { log.Errorf(ctx, "error refetching emojis: %s", err) } else { diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index e4a49cc45..942cecc59 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -20,6 +20,7 @@ package common import ( "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -29,6 +30,7 @@ import ( // processing subsection of the codebase. type Processor struct { state *state.State + media *media.Manager converter *typeutils.Converter federator *federation.Federator filter *visibility.Filter @@ -37,12 +39,14 @@ type Processor struct { // New returns a new Processor instance. func New( state *state.State, + media *media.Manager, converter *typeutils.Converter, federator *federation.Federator, filter *visibility.Filter, ) Processor { return Processor{ state: state, + media: media, converter: converter, federator: federator, filter: filter, diff --git a/internal/processing/common/media.go b/internal/processing/common/media.go new file mode 100644 index 000000000..7baf30345 --- /dev/null +++ b/internal/processing/common/media.go @@ -0,0 +1,98 @@ +// 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 common + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// StoreLocalMedia is a wrapper around CreateMedia() and +// ProcessingMedia{}.Load() with appropriate error responses. +func (p *Processor) StoreLocalMedia( + ctx context.Context, + accountID string, + data media.DataFunc, + info media.AdditionalMediaInfo, +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + // Create a new processing media attachment. + processing, err := p.media.CreateMedia(ctx, + accountID, + data, + info, + ) + if err != nil { + err := gtserror.Newf("error creating media: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Immediately trigger write to storage. + attachment, err := processing.Load(ctx) + if err != nil { + const text = "error processing emoji" + err := gtserror.Newf("error processing media: %w", err) + return nil, gtserror.NewErrorUnprocessableEntity(err, text) + } else if attachment.Type == gtsmodel.FileTypeUnknown { + text := fmt.Sprintf("could not process %s type media", attachment.File.ContentType) + return nil, gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + return attachment, nil +} + +// StoreLocalMedia is a wrapper around CreateMedia() and +// ProcessingMedia{}.Load() with appropriate error responses. +func (p *Processor) StoreLocalEmoji( + ctx context.Context, + shortcode string, + data media.DataFunc, + info media.AdditionalEmojiInfo, +) ( + *gtsmodel.Emoji, + gtserror.WithCode, +) { + // Create a new processing emoji media. + processing, err := p.media.CreateEmoji(ctx, + shortcode, + "", // domain = "" -> local + data, + info, + ) + if err != nil { + err := gtserror.Newf("error creating emoji: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Immediately write to storage. + emoji, err := processing.Load(ctx) + if err != nil { + const text = "error processing emoji" + err := gtserror.Newf("error processing emoji %s: %w", shortcode, err) + return nil, gtserror.NewErrorUnprocessableEntity(err, text) + } + + return emoji, nil +} diff --git a/internal/processing/instance.go b/internal/processing/instance.go index a93936425..a9be6db1d 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -246,9 +246,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe if form.Avatar != nil && form.Avatar.Size != 0 { // Process instance avatar image + description. - avatarInfo, err := p.account.UpdateAvatar(ctx, form.Avatar, form.AvatarDescription, instanceAcc.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err, "error processing avatar") + avatarInfo, errWithCode := p.account.UpdateAvatar(ctx, + instanceAcc, + form.Avatar, + form.AvatarDescription, + ) + if errWithCode != nil { + return nil, errWithCode } instanceAcc.AvatarMediaAttachmentID = avatarInfo.ID instanceAcc.AvatarMediaAttachment = avatarInfo @@ -264,9 +268,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe if form.Header != nil && form.Header.Size != 0 { // process instance header image - headerInfo, err := p.account.UpdateHeader(ctx, form.Header, nil, instanceAcc.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err, "error processing header") + headerInfo, errWithCode := p.account.UpdateHeader(ctx, + instanceAcc, + form.Header, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } instanceAcc.HeaderMediaAttachmentID = headerInfo.ID instanceAcc.HeaderMediaAttachment = headerInfo diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index fe20457b4..0dbe997de 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -30,7 +30,7 @@ import ( // Create creates a new media attachment belonging to the given account, using the request form. func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) { - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := form.File.Open() return f, form.File.Size, err } @@ -41,19 +41,18 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - // process the media attachment and load it immediately - media := p.mediaManager.PreProcessMedia(data, account.ID, &media.AdditionalMediaInfo{ - Description: &form.Description, - FocusX: &focusX, - FocusY: &focusY, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + // Create local media and write to instance storage. + attachment, errWithCode := p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Description: &form.Description, + FocusX: &focusX, + FocusY: &focusY, + }, + ) + if errWithCode != nil { + return nil, errWithCode } apiAttachment, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 28f5e6464..7ba549029 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -19,14 +19,14 @@ package media import ( "context" + "errors" "fmt" - "io" "net/url" "strings" "time" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -38,7 +38,7 @@ import ( // to the caller via an io.reader embedded in *apimodel.Content. func (p *Processor) GetFile( ctx context.Context, - requestingAccount *gtsmodel.Account, + requester *gtsmodel.Account, form *apimodel.GetContentRequestForm, ) (*apimodel.Content, gtserror.WithCode) { // parse the form fields @@ -69,13 +69,13 @@ func (p *Processor) GetFile( } // make sure the requesting account and the media account don't block each other - if requestingAccount != nil { - blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, owningAccountID) + if requester != nil { + blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, owningAccountID) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requestingAccount.ID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requester.ID, err)) } if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requester.ID)) } } @@ -83,71 +83,78 @@ func (p *Processor) GetFile( // so we need to take different steps depending on the media type being requested switch mediaType { case media.TypeEmoji: - return p.getEmojiContent(ctx, wantedMediaID, owningAccountID, mediaSize) + return p.getEmojiContent(ctx, + owningAccountID, + wantedMediaID, + mediaSize, + ) case media.TypeAttachment, media.TypeHeader, media.TypeAvatar: - return p.getAttachmentContent(ctx, requestingAccount, wantedMediaID, owningAccountID, mediaSize) + return p.getAttachmentContent(ctx, + requester, + owningAccountID, + wantedMediaID, + mediaSize, + ) default: return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not recognized", mediaType)) } } -/* - UTIL FUNCTIONS -*/ - -func parseType(s string) (media.Type, error) { - switch s { - case string(media.TypeAttachment): - return media.TypeAttachment, nil - case string(media.TypeHeader): - return media.TypeHeader, nil - case string(media.TypeAvatar): - return media.TypeAvatar, nil - case string(media.TypeEmoji): - return media.TypeEmoji, nil +func (p *Processor) getAttachmentContent( + ctx context.Context, + requester *gtsmodel.Account, + ownerID string, + mediaID string, + sizeStr media.Size, +) ( + *apimodel.Content, + gtserror.WithCode, +) { + // Search for media with given ID in the database. + attach, err := p.state.DB.GetAttachmentByID(ctx, mediaID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching media from database: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - return "", fmt.Errorf("%s not a recognized media.Type", s) -} -func parseSize(s string) (media.Size, error) { - switch s { - case string(media.SizeSmall): - return media.SizeSmall, nil - case string(media.SizeOriginal): - return media.SizeOriginal, nil - case string(media.SizeStatic): - return media.SizeStatic, nil + if attach == nil { + const text = "media not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) } - return "", fmt.Errorf("%s not a recognized media.Size", s) -} -func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount *gtsmodel.Account, wantedMediaID string, owningAccountID string, mediaSize media.Size) (*apimodel.Content, gtserror.WithCode) { - // retrieve attachment from the database and do basic checks on it - a, err := p.state.DB.GetAttachmentByID(ctx, wantedMediaID) - if err != nil { - err = gtserror.Newf("attachment %s could not be taken from the db: %w", wantedMediaID, err) - return nil, gtserror.NewErrorNotFound(err) + // Ensure the 'owner' owns media. + if attach.AccountID != ownerID { + const text = "media was not owned by passed account id" + return nil, gtserror.NewErrorNotFound(errors.New(text) /* no help text! */) } - if a.AccountID != owningAccountID { - err = gtserror.Newf("attachment %s is not owned by %s", wantedMediaID, owningAccountID) - return nil, gtserror.NewErrorNotFound(err) - } + var remoteURL *url.URL + if attach.RemoteURL != "" { - // If this is an "Unknown" file type, ie., one we - // tried to process and couldn't, or one we refused - // to process because it wasn't supported, then we - // can skip a lot of steps here by simply forwarding - // the request to the remote URL. - if a.Type == gtsmodel.FileTypeUnknown { - remoteURL, err := url.Parse(a.RemoteURL) + // Parse media remote URL to valid URL object. + remoteURL, err = url.Parse(attach.RemoteURL) if err != nil { - err = gtserror.Newf("error parsing remote URL of 'Unknown'-type attachment for redirection: %w", err) + err := gtserror.Newf("invalid media remote url %s: %w", attach.RemoteURL, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // Uknown file types indicate no *locally* + // stored data we can serve. Handle separately. + if attach.Type == gtsmodel.FileTypeUnknown { + if remoteURL == nil { + err := gtserror.Newf("missing remote url for unknown type media %s: %w", attach.ID, err) return nil, gtserror.NewErrorInternalError(err) } + // If this is an "Unknown" file type, ie., one we + // tried to process and couldn't, or one we refused + // to process because it wasn't supported, then we + // can skip a lot of steps here by simply forwarding + // the request to the remote URL. url := &storage.PresignedURL{ URL: remoteURL, + // We might manage to cache the media // at some point, so set a low-ish expiry. Expiry: time.Now().Add(2 * time.Hour), @@ -156,162 +163,197 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount return &apimodel.Content{URL: url}, nil } - if !*a.Cached { - // if we don't have it cached, then we can assume two things: - // 1. this is remote media, since local media should never be uncached - // 2. we need to fetch it again using a transport and the media manager - remoteMediaIRI, err := url.Parse(a.RemoteURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %w", a.RemoteURL, err)) - } - - // use an empty string as requestingUsername to use the instance account, unless the request for this - // media has been http signed, then use the requesting account to make the request to remote server - var requestingUsername string - if requestingAccount != nil { - requestingUsername = requestingAccount.Username - } + var requestUser string - // Pour one out for tobi's original streamed recache - // (streaming data both to the client and storage). - // Gone and forever missed <3 - // - // [ - // the reason it was removed was because a slow - // client connection could hold open a storage - // recache operation -> holding open a media worker. - // ] - - dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(ctx, requestingUsername) - if err != nil { - return nil, 0, err - } - return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteMediaIRI) - } + if requester != nil { + // Set requesting acc username. + requestUser = requester.Username + } - // Start recaching this media with the prepared data function. - processingMedia, err := p.mediaManager.PreProcessMediaRecache(ctx, dataFn, wantedMediaID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %w", err)) - } + // Ensure that stored media is cached. + // (this handles local media / recaches). + attach, err = p.federator.RefreshMedia( + ctx, + requestUser, + attach, + media.AdditionalMediaInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching media: %w", err) + return nil, gtserror.NewErrorNotFound(err) + } - // Load attachment and block until complete - a, err = processingMedia.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %w", err)) - } + // Start preparing API content model. + apiContent := &apimodel.Content{ + ContentUpdated: attach.UpdatedAt, } - var ( - storagePath string - attachmentContent = &apimodel.Content{ - ContentUpdated: a.UpdatedAt, - } - ) + // Retrieve appropriate + // size file from storage. + switch sizeStr { - // get file information from the attachment depending on the requested media size - switch mediaSize { case media.SizeOriginal: - attachmentContent.ContentType = a.File.ContentType - attachmentContent.ContentLength = int64(a.File.FileSize) - storagePath = a.File.Path + apiContent.ContentType = attach.File.ContentType + apiContent.ContentLength = int64(attach.File.FileSize) + return p.getContent(ctx, + attach.File.Path, + apiContent, + ) + case media.SizeSmall: - attachmentContent.ContentType = a.Thumbnail.ContentType - attachmentContent.ContentLength = int64(a.Thumbnail.FileSize) - storagePath = a.Thumbnail.Path + apiContent.ContentType = attach.Thumbnail.ContentType + apiContent.ContentLength = int64(attach.Thumbnail.FileSize) + return p.getContent(ctx, + attach.Thumbnail.Path, + apiContent, + ) + default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) + const text = "invalid media attachment size" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - - // ... so now we can safely return it - return p.retrieveFromStorage(ctx, storagePath, attachmentContent) } -func (p *Processor) getEmojiContent(ctx context.Context, fileName string, owningAccountID string, emojiSize media.Size) (*apimodel.Content, gtserror.WithCode) { - emojiContent := &apimodel.Content{} - var storagePath string +func (p *Processor) getEmojiContent( + ctx context.Context, - // reconstruct the static emoji image url -- reason - // for using the static URL rather than full size url - // is that static emojis are always encoded as png, - // so this is more reliable than using full size url - imageStaticURL := uris.URIForAttachment( - owningAccountID, + ownerID string, + emojiID string, + sizeStr media.Size, +) ( + *apimodel.Content, + gtserror.WithCode, +) { + // Reconstruct static emoji image URL to search for it. + // As refreshed emojis use a newly generated path ID to + // differentiate them (cache-wise) from the original. + staticURL := uris.URIForAttachment( + ownerID, string(media.TypeEmoji), string(media.SizeStatic), - fileName, + emojiID, "png", ) - e, err := p.state.DB.GetEmojiByStaticURL(ctx, imageStaticURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %w", fileName, err)) + // Search for emoji with given static URL in the database. + emoji, err := p.state.DB.GetEmojiByStaticURL(ctx, staticURL) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching emoji from database: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - if *e.Disabled { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", fileName)) + if emoji == nil { + const text = "emoji not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) } - if !*e.Cached { - // if we don't have it cached, then we can assume two things: - // 1. this is remote emoji, since local emoji should never be uncached - // 2. we need to fetch it again using a transport and the media manager - remoteURL, err := url.Parse(e.ImageRemoteURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote emoji iri %s: %w", e.ImageRemoteURL, err)) - } + if *emoji.Disabled { + const text = "emoji has been disabled" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } - dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(ctx, "") - if err != nil { - return nil, 0, err - } - return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteURL) - } + // Ensure that stored emoji is cached. + // (this handles local emoji / recaches). + emoji, err = p.federator.RefreshEmoji( + ctx, + emoji, + media.AdditionalEmojiInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching emoji: %w", err) + return nil, gtserror.NewErrorNotFound(err) + } - // Start recaching this emoji with the prepared data function. - processingEmoji, err := p.mediaManager.PreProcessEmojiRecache(ctx, dataFn, e.ID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching emoji: %w", err)) - } + // Start preparing API content model. + apiContent := &apimodel.Content{} - // Load attachment and block until complete - e, err = processingEmoji.LoadEmoji(ctx) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached emoji: %w", err)) - } - } + // Retrieve appropriate + // size file from storage. + switch sizeStr { - switch emojiSize { case media.SizeOriginal: - emojiContent.ContentType = e.ImageContentType - emojiContent.ContentLength = int64(e.ImageFileSize) - storagePath = e.ImagePath + apiContent.ContentType = emoji.ImageContentType + apiContent.ContentLength = int64(emoji.ImageFileSize) + return p.getContent(ctx, + emoji.ImagePath, + apiContent, + ) + case media.SizeStatic: - emojiContent.ContentType = e.ImageStaticContentType - emojiContent.ContentLength = int64(e.ImageStaticFileSize) - storagePath = e.ImageStaticPath + apiContent.ContentType = emoji.ImageStaticContentType + apiContent.ContentLength = int64(emoji.ImageStaticFileSize) + return p.getContent(ctx, + emoji.ImageStaticPath, + apiContent, + ) + default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", emojiSize)) + const text = "invalid media attachment size" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - - return p.retrieveFromStorage(ctx, storagePath, emojiContent) } -func (p *Processor) retrieveFromStorage(ctx context.Context, storagePath string, content *apimodel.Content) (*apimodel.Content, gtserror.WithCode) { +// getContent performs the final file fetching of +// stored content at path in storage. This is +// populated in the apimodel.Content{} and returned. +// (note: this also handles un-proxied S3 storage). +func (p *Processor) getContent( + ctx context.Context, + path string, + content *apimodel.Content, +) ( + *apimodel.Content, + gtserror.WithCode, +) { // If running on S3 storage with proxying disabled then - // just fetch a pre-signed URL instead of serving the content. - if url := p.state.Storage.URL(ctx, storagePath); url != nil { + // just fetch pre-signed URL instead of the content. + if url := p.state.Storage.URL(ctx, path); url != nil { content.URL = url return content, nil } - reader, err := p.state.Storage.GetStream(ctx, storagePath) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) + // Fetch file stream for the stored media at path. + rc, err := p.state.Storage.GetStream(ctx, path) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("error getting file %s from storage: %w", path, err) + return nil, gtserror.NewErrorInternalError(err) } - content.Content = reader + // Ensure found. + if rc == nil { + const text = "file not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } + + // Return with stream. + content.Content = rc return content, nil } + +func parseType(s string) (media.Type, error) { + switch s { + case string(media.TypeAttachment): + return media.TypeAttachment, nil + case string(media.TypeHeader): + return media.TypeHeader, nil + case string(media.TypeAvatar): + return media.TypeAvatar, nil + case string(media.TypeEmoji): + return media.TypeEmoji, nil + } + return "", fmt.Errorf("%s not a recognized media.Type", s) +} + +func parseSize(s string) (media.Size, error) { + switch s { + case string(media.SizeSmall): + return media.SizeSmall, nil + case string(media.SizeOriginal): + return media.SizeOriginal, nil + case string(media.SizeStatic): + return media.SizeStatic, nil + } + return "", fmt.Errorf("%s not a recognized media.Size", s) +} diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go index 22c455920..76ed68f5a 100644 --- a/internal/processing/media/media.go +++ b/internal/processing/media/media.go @@ -18,24 +18,39 @@ package media import ( + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type Processor struct { + // common processor logic + c *common.Processor + state *state.State converter *typeutils.Converter + federator *federation.Federator mediaManager *media.Manager transportController transport.Controller } // New returns a new media processor. -func New(state *state.State, converter *typeutils.Converter, mediaManager *media.Manager, transportController transport.Controller) Processor { +func New( + common *common.Processor, + state *state.State, + converter *typeutils.Converter, + federator *federation.Federator, + mediaManager *media.Manager, + transportController transport.Controller, +) Processor { return Processor{ + c: common, state: state, converter: converter, + federator: federator, mediaManager: mediaManager, transportController: transportController, } diff --git a/internal/processing/media/media_test.go b/internal/processing/media/media_test.go index 523428140..80f1a7be7 100644 --- a/internal/processing/media/media_test.go +++ b/internal/processing/media/media_test.go @@ -20,8 +20,10 @@ package media_test import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" mediaprocessing "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" @@ -78,7 +80,12 @@ func (suite *MediaStandardTestSuite) SetupTest() { suite.state.Storage = suite.storage suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) - suite.mediaProcessor = mediaprocessing.New(&suite.state, suite.tc, suite.mediaManager, suite.transportController) + + federator := testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) + filter := visibility.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, filter) + + suite.mediaProcessor = mediaprocessing.New(&common, &suite.state, suite.tc, federator, suite.mediaManager, suite.transportController) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") } diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index 847612503..bf6ae4aad 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -57,7 +57,7 @@ func (suite *PollTestSuite) SetupTest() { mediaMgr := media.NewManager(&suite.state) federator := testrig.NewTestFederator(&suite.state, controller, mediaMgr) suite.filter = visibility.NewFilter(&suite.state) - common := common.New(&suite.state, converter, federator, suite.filter) + common := common.New(&suite.state, mediaMgr, converter, federator, suite.filter) suite.polls = polls.New(&common, &suite.state, converter) } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8765819d3..fb6b05d80 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -179,15 +179,15 @@ func NewProcessor( // // Start with sub processors that will // be required by the workers processor. - common := common.New(state, converter, federator, filter) + common := common.New(state, mediaManager, converter, federator, filter) processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) - processor.media = media.New(state, converter, mediaManager, federator.TransportController()) + processor.media = media.New(&common, state, converter, federator, mediaManager, federator.TransportController()) processor.stream = stream.New(state, oauthServer) // Instantiate the rest of the sub // processors + pin them to this struct. processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) - processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) + processor.admin = admin.New(&common, state, cleaner, federator, converter, mediaManager, federator.TransportController(), emailSender) processor.fedi = fedi.New(state, &common, converter, federator, filter) processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 171e4b488..9eba78ec6 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -96,7 +96,7 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.typeConverter, ) - common := common.New(&suite.state, suite.typeConverter, suite.federator, filter) + common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, filter) polls := polls.New(&common, &suite.state, suite.typeConverter) suite.status = status.New(&suite.state, &common, &polls, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) |