From c4d63d125b5a44c150a00b0b20b3638cad9221f8 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Tue, 28 Dec 2021 16:36:00 +0100 Subject: more refactoring, media handler => manager --- internal/processing/media/create.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'internal/processing/media/create.go') diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index de15d3162..68a011683 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -65,8 +65,8 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form }, } - // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := p.mediaHandler.ProcessAttachment(ctx, buf.Bytes(), minAttachment) + // allow the mediaManager to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using + attachment, err := p.mediaManager.ProcessAttachment(ctx, buf.Bytes(), minAttachment) if err != nil { return nil, fmt.Errorf("error reading attachment: %s", err) } -- cgit v1.2.3 From c2ff8f392b6320d45d8667d4e093e8eb8ddf59c1 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Sat, 8 Jan 2022 13:45:42 +0100 Subject: further refinements --- internal/media/image.go | 70 ----------------------- internal/media/manager.go | 56 ++++++++++++++++++ internal/media/media.go | 110 +++++++++++++++++++++++------------- internal/processing/media/create.go | 26 ++------- 4 files changed, 131 insertions(+), 131 deletions(-) (limited to 'internal/processing/media/create.go') diff --git a/internal/media/image.go b/internal/media/image.go index 157ae0f4a..acc62a28b 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -20,22 +20,16 @@ package media import ( "bytes" - "context" "errors" "fmt" "image" "image/gif" "image/jpeg" "image/png" - "strings" - "time" "github.com/buckket/go-blurhash" "github.com/nfnt/resize" "github.com/superseriousbusiness/exifremove/pkg/exifremove" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/uris" ) const ( @@ -53,70 +47,6 @@ type ImageMeta struct { blurhash string } -func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string) (*Media, error) { - if !supportedImage(contentType) { - return nil, fmt.Errorf("image type %s not supported", contentType) - } - - if len(data) == 0 { - return nil, errors.New("image was of size 0") - } - - id, err := id.NewRandomULID() - if err != nil { - return nil, err - } - - extension := strings.Split(contentType, "/")[1] - - attachment := >smodel.MediaAttachment{ - ID: id, - UpdatedAt: time.Now(), - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), - Type: gtsmodel.FileTypeImage, - AccountID: accountID, - Processing: 0, - File: gtsmodel.File{ - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), - ContentType: contentType, - UpdatedAt: time.Now(), - }, - Thumbnail: gtsmodel.Thumbnail{ - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, - ContentType: mimeJpeg, - UpdatedAt: time.Now(), - }, - Avatar: false, - Header: false, - } - - media := &Media{ - attachment: attachment, - } - - return media, nil - - var clean []byte - var original *ImageMeta - var small *ImageMeta - - - - if err != nil { - return nil, err - } - - small, err = deriveThumbnail(clean, contentType) - if err != nil { - return nil, fmt.Errorf("error deriving thumbnail: %s", err) - } - - // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it - - return attachment, nil -} - func decodeGif(b []byte) (*ImageMeta, error) { gif, err := gif.DecodeAll(bytes.NewReader(b)) if err != nil { diff --git a/internal/media/manager.go b/internal/media/manager.go index 074ebdb58..8032ab42d 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -24,11 +24,15 @@ import ( "fmt" "runtime" "strings" + "time" "codeberg.org/gruf/go-runners" "codeberg.org/gruf/go-store/kv" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" ) // Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs. @@ -92,6 +96,7 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin // if the inner context is done that means the worker pool is closing, so we should just return return default: + // start preloading the media for the caller's convenience media.PreLoad(innerCtx) } }) @@ -101,3 +106,54 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin return nil, fmt.Errorf("content type %s not (yet) supported", contentType) } } + +// preProcessImage initializes processing +func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string) (*Media, error) { + if !supportedImage(contentType) { + return nil, fmt.Errorf("image type %s not supported", contentType) + } + + if len(data) == 0 { + return nil, errors.New("image was of size 0") + } + + id, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + extension := strings.Split(contentType, "/")[1] + + attachment := >smodel.MediaAttachment{ + ID: id, + UpdatedAt: time.Now(), + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), + Type: gtsmodel.FileTypeImage, + AccountID: accountID, + Processing: 0, + File: gtsmodel.File{ + Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), + ContentType: contentType, + UpdatedAt: time.Now(), + }, + Thumbnail: gtsmodel.Thumbnail{ + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, + Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, + ContentType: mimeJpeg, + UpdatedAt: time.Now(), + }, + Avatar: false, + Header: false, + } + + media := &Media{ + attachment: attachment, + rawData: data, + thumbstate: received, + fullSizeState: received, + database: m.db, + storage: m.storage, + } + + return media, nil +} diff --git a/internal/media/media.go b/internal/media/media.go index aa11787b2..022de063e 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -33,15 +33,15 @@ type Media struct { below fields represent the processing state of the media thumbnail */ - thumbing processState - thumb *ImageMeta + thumbstate processState + thumb *ImageMeta /* below fields represent the processing state of the full-sized media */ - processing processState - processed *ImageMeta + fullSizeState processState + fullSize *ImageMeta /* below pointers to database and storage are maintained so that @@ -58,20 +58,20 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { m.mu.Lock() defer m.mu.Unlock() - switch m.thumbing { + switch m.thumbstate { case received: // we haven't processed a thumbnail for this media yet so do it now thumb, err := deriveThumbnail(m.rawData, m.attachment.File.ContentType) if err != nil { m.err = fmt.Errorf("error deriving thumbnail: %s", err) - m.thumbing = errored + m.thumbstate = errored return nil, m.err } // put the thumbnail in storage if err := m.storage.Put(m.attachment.Thumbnail.Path, thumb.image); err != nil { m.err = fmt.Errorf("error storing thumbnail: %s", err) - m.thumbing = errored + m.thumbstate = errored return nil, m.err } @@ -89,12 +89,12 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { if err := m.database.Put(ctx, m.attachment); err != nil { if err != db.ErrAlreadyExists { m.err = fmt.Errorf("error putting attachment: %s", err) - m.thumbing = errored + m.thumbstate = errored return nil, m.err } if err := m.database.UpdateByPrimaryKey(ctx, m.attachment); err != nil { m.err = fmt.Errorf("error updating attachment: %s", err) - m.thumbing = errored + m.thumbstate = errored return nil, m.err } } @@ -103,7 +103,7 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { m.thumb = thumb // we're done processing the thumbnail! - m.thumbing = complete + m.thumbstate = complete fallthrough case complete: return m.thumb, nil @@ -111,46 +111,76 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { return nil, m.err } - return nil, fmt.Errorf("thumbnail processing status %d unknown", m.thumbing) + return nil, fmt.Errorf("thumbnail processing status %d unknown", m.thumbstate) } -func (m *Media) Full(ctx context.Context) (*ImageMeta, error) { - var clean []byte - var err error - var original *ImageMeta - - ct := m.attachment.File.ContentType -aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - switch ct { - case mimeImageJpeg, mimeImagePng: - // first 'clean' image by purging exif data from it - var exifErr error - if clean, exifErr = purgeExif(m.rawData); exifErr != nil { - return nil, fmt.Errorf("error cleaning exif data: %s", exifErr) +func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { + m.mu.Lock() + defer m.mu.Unlock() + + switch m.fullSizeState { + case received: + var clean []byte + var err error + var decoded *ImageMeta + + ct := m.attachment.File.ContentType + switch ct { + case mimeImageJpeg, mimeImagePng: + // first 'clean' image by purging exif data from it + var exifErr error + if clean, exifErr = purgeExif(m.rawData); exifErr != nil { + err = exifErr + break + } + decoded, err = decodeImage(clean, ct) + case mimeImageGif: + // gifs are already clean - no exif data to remove + clean = m.rawData + decoded, err = decodeGif(clean) + default: + err = fmt.Errorf("content type %s not a processible image type", ct) } - original, err = decodeImage(clean, ct) - case mimeImageGif: - // gifs are already clean - no exif data to remove - clean = m.rawData - original, err = decodeGif(clean) - default: - err = fmt.Errorf("content type %s not a processible image type", ct) - } - if err != nil { - return nil, err + if err != nil { + m.err = err + m.fullSizeState = errored + return nil, err + } + + // set the fullsize of this media + m.fullSize = decoded + + // we're done processing the full-size image + m.fullSizeState = complete + fallthrough + case complete: + return m.fullSize, nil + case errored: + return nil, m.err } - return original, nil + return nil, fmt.Errorf("full size processing status %d unknown", m.fullSizeState) } +// PreLoad begins the process of deriving the thumbnail and encoding the full-size image. +// It does this in a non-blocking way, so you can call it and then come back later and check +// if it's finished. func (m *Media) PreLoad(ctx context.Context) { go m.Thumb(ctx) - m.mu.Lock() - defer m.mu.Unlock() + go m.FullSize(ctx) } -func (m *Media) Load() { - m.mu.Lock() - defer m.mu.Unlock() +// Load is the blocking equivalent of pre-load. It makes sure the thumbnail and full-size image +// have been processed, then it returns the full-size image. +func (m *Media) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { + if _, err := m.Thumb(ctx); err != nil { + return nil, err + } + + if _, err := m.FullSize(ctx); err != nil { + return nil, err + } + + return m.attachment, nil } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 68a011683..357278e64 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -24,11 +24,9 @@ import ( "errors" "fmt" "io" - "time" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/text" ) func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { @@ -46,29 +44,15 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, errors.New("could not read provided attachment: size 0 bytes") } - // now parse the focus parameter - focusx, focusy, err := parseFocus(form.Focus) + // process the media and load it immediately + media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID) if err != nil { - return nil, fmt.Errorf("couldn't parse attachment focus: %s", err) + return nil, err } - minAttachment := >smodel.MediaAttachment{ - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - AccountID: account.ID, - Description: text.SanitizeCaption(form.Description), - FileMeta: gtsmodel.FileMeta{ - Focus: gtsmodel.Focus{ - X: focusx, - Y: focusy, - }, - }, - } - - // allow the mediaManager to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := p.mediaManager.ProcessAttachment(ctx, buf.Bytes(), minAttachment) + attachment, err := media.Load(ctx) if err != nil { - return nil, fmt.Errorf("error reading attachment: %s", err) + return nil, err } // prepare the frontend representation now -- if there are any errors here at least we can bail without -- cgit v1.2.3 From f61c3ddcf72ff689b9d253546c58d499b6fe6ac8 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Sat, 8 Jan 2022 17:17:01 +0100 Subject: compiling now --- internal/api/client/admin/emojicreate.go | 6 -- internal/db/bundb/errors.go | 2 +- internal/federation/dereferencing/account.go | 48 ++++++--- internal/federation/dereferencing/attachment.go | 102 ------------------- .../federation/dereferencing/attachment_test.go | 106 ------------------- internal/federation/dereferencing/dereferencer.go | 29 +----- internal/federation/dereferencing/media.go | 55 ++++++++++ internal/federation/dereferencing/media_test.go | 102 +++++++++++++++++++ internal/federation/dereferencing/status.go | 10 +- internal/media/manager.go | 25 ++++- internal/media/manager_test.go | 4 + internal/media/media.go | 113 ++++++++++++++++++--- internal/media/media_test.go | 65 ++++++++++++ internal/processing/account/update.go | 47 ++++++--- internal/processing/admin/emoji.go | 13 +-- internal/processing/media/create.go | 11 +- internal/transport/derefmedia.go | 9 +- internal/transport/transport.go | 2 +- testrig/mediahandler.go | 6 +- 19 files changed, 437 insertions(+), 318 deletions(-) delete mode 100644 internal/federation/dereferencing/attachment.go delete mode 100644 internal/federation/dereferencing/attachment_test.go create mode 100644 internal/federation/dereferencing/media.go create mode 100644 internal/federation/dereferencing/media_test.go create mode 100644 internal/media/manager_test.go create mode 100644 internal/media/media_test.go (limited to 'internal/processing/media/create.go') diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go index 617add413..882654ea9 100644 --- a/internal/api/client/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -27,7 +27,6 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/validate" ) @@ -133,10 +132,5 @@ func validateCreateEmoji(form *model.EmojiCreateRequest) error { return errors.New("no emoji given") } - // a very superficial check to see if the media size limit is exceeded - if form.Image.Size > media.EmojiMaxBytes { - return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) - } - return validate.EmojiShortcode(form.Shortcode) } diff --git a/internal/db/bundb/errors.go b/internal/db/bundb/errors.go index 7602d5e1d..7d0157373 100644 --- a/internal/db/bundb/errors.go +++ b/internal/db/bundb/errors.go @@ -35,7 +35,7 @@ func processSQLiteError(err error) db.Error { // Handle supplied error code: switch sqliteErr.Code() { - case sqlite3.SQLITE_CONSTRAINT_UNIQUE: + case sqlite3.SQLITE_CONSTRAINT_UNIQUE, sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY: return db.ErrAlreadyExists default: return err diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 19c98e203..5912ff29a 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -246,25 +246,49 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * } if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "" || refresh) { - a, err := d.mediaManager.ProcessRemoteHeaderOrAvatar(ctx, t, >smodel.MediaAttachment{ - RemoteURL: targetAccount.AvatarRemoteURL, - Avatar: true, - }, targetAccount.ID) + avatarIRI, err := url.Parse(targetAccount.AvatarRemoteURL) if err != nil { - return fmt.Errorf("error processing avatar for user: %s", err) + return err } - targetAccount.AvatarMediaAttachmentID = a.ID + + data, err := t.DereferenceMedia(ctx, avatarIRI) + if err != nil { + return err + } + + media, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, targetAccount.AvatarRemoteURL) + if err != nil { + return err + } + + if err := media.SetAsAvatar(ctx); err != nil { + return err + } + + targetAccount.AvatarMediaAttachmentID = media.AttachmentID() } if targetAccount.HeaderRemoteURL != "" && (targetAccount.HeaderMediaAttachmentID == "" || refresh) { - a, err := d.mediaManager.ProcessRemoteHeaderOrAvatar(ctx, t, >smodel.MediaAttachment{ - RemoteURL: targetAccount.HeaderRemoteURL, - Header: true, - }, targetAccount.ID) + headerIRI, err := url.Parse(targetAccount.HeaderRemoteURL) if err != nil { - return fmt.Errorf("error processing header for user: %s", err) + return err } - targetAccount.HeaderMediaAttachmentID = a.ID + + data, err := t.DereferenceMedia(ctx, headerIRI) + if err != nil { + return err + } + + media, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, targetAccount.HeaderRemoteURL) + if err != nil { + return err + } + + if err := media.SetAsHeader(ctx); err != nil { + return err + } + + targetAccount.HeaderMediaAttachmentID = media.AttachmentID() } return nil } diff --git a/internal/federation/dereferencing/attachment.go b/internal/federation/dereferencing/attachment.go deleted file mode 100644 index 30ab6da10..000000000 --- a/internal/federation/dereferencing/attachment.go +++ /dev/null @@ -1,102 +0,0 @@ -/* - 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 dereferencing - -import ( - "context" - "fmt" - "net/url" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -func (d *deref) GetRemoteAttachment(ctx context.Context, requestingUsername string, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { - if minAttachment.RemoteURL == "" { - return nil, fmt.Errorf("GetRemoteAttachment: minAttachment remote URL was empty") - } - remoteAttachmentURL := minAttachment.RemoteURL - - l := logrus.WithFields(logrus.Fields{ - "username": requestingUsername, - "remoteAttachmentURL": remoteAttachmentURL, - }) - - // return early if we already have the attachment somewhere - maybeAttachment := >smodel.MediaAttachment{} - where := []db.Where{ - { - Key: "remote_url", - Value: remoteAttachmentURL, - }, - } - - if err := d.db.GetWhere(ctx, where, maybeAttachment); err == nil { - // we already the attachment in the database - l.Debugf("GetRemoteAttachment: attachment already exists with id %s", maybeAttachment.ID) - return maybeAttachment, nil - } - - a, err := d.RefreshAttachment(ctx, requestingUsername, minAttachment) - if err != nil { - return nil, fmt.Errorf("GetRemoteAttachment: error refreshing attachment: %s", err) - } - - if err := d.db.Put(ctx, a); err != nil { - if err != db.ErrAlreadyExists { - return nil, fmt.Errorf("GetRemoteAttachment: error inserting attachment: %s", err) - } - } - - return a, nil -} - -func (d *deref) RefreshAttachment(ctx context.Context, requestingUsername string, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { - // it just doesn't exist or we have to refresh - if minAttachment.AccountID == "" { - return nil, fmt.Errorf("RefreshAttachment: minAttachment account ID was empty") - } - - if minAttachment.File.ContentType == "" { - return nil, fmt.Errorf("RefreshAttachment: minAttachment.file.contentType was empty") - } - - t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) - if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error creating transport: %s", err) - } - - derefURI, err := url.Parse(minAttachment.RemoteURL) - if err != nil { - return nil, err - } - - attachmentBytes, err := t.DereferenceMedia(ctx, derefURI, minAttachment.File.ContentType) - if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error dereferencing media: %s", err) - } - - a, err := d.mediaManager.ProcessAttachment(ctx, attachmentBytes, minAttachment) - if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error processing attachment: %s", err) - } - - return a, nil -} diff --git a/internal/federation/dereferencing/attachment_test.go b/internal/federation/dereferencing/attachment_test.go deleted file mode 100644 index d07cf1c6a..000000000 --- a/internal/federation/dereferencing/attachment_test.go +++ /dev/null @@ -1,106 +0,0 @@ -/* - 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 dereferencing_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type AttachmentTestSuite struct { - DereferencerStandardTestSuite -} - -func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { - fetchingAccount := suite.testAccounts["local_account_1"] - - attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM" - attachmentStatus := "01FENS9NTTVNEX1YZV7GB63MT8" - attachmentContentType := "image/jpeg" - attachmentURL := "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg" - attachmentDescription := "It's a cute plushie." - - minAttachment := >smodel.MediaAttachment{ - RemoteURL: attachmentURL, - AccountID: attachmentOwner, - StatusID: attachmentStatus, - File: gtsmodel.File{ - ContentType: attachmentContentType, - }, - Description: attachmentDescription, - } - - attachment, err := suite.dereferencer.GetRemoteAttachment(context.Background(), fetchingAccount.Username, minAttachment) - suite.NoError(err) - suite.NotNil(attachment) - - suite.Equal(attachmentOwner, attachment.AccountID) - suite.Equal(attachmentStatus, attachment.StatusID) - suite.Equal(attachmentURL, attachment.RemoteURL) - suite.NotEmpty(attachment.URL) - suite.NotEmpty(attachment.Blurhash) - suite.NotEmpty(attachment.ID) - suite.NotEmpty(attachment.CreatedAt) - suite.NotEmpty(attachment.UpdatedAt) - suite.Equal(1.336546184738956, attachment.FileMeta.Original.Aspect) - suite.Equal(2071680, attachment.FileMeta.Original.Size) - suite.Equal(1245, attachment.FileMeta.Original.Height) - suite.Equal(1664, attachment.FileMeta.Original.Width) - suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", attachment.Blurhash) - suite.Equal(gtsmodel.ProcessingStatusProcessed, attachment.Processing) - suite.NotEmpty(attachment.File.Path) - suite.Equal(attachmentContentType, attachment.File.ContentType) - suite.Equal(attachmentDescription, attachment.Description) - - suite.NotEmpty(attachment.Thumbnail.Path) - suite.NotEmpty(attachment.Type) - - // attachment should also now be in the database - dbAttachment, err := suite.db.GetAttachmentByID(context.Background(), attachment.ID) - suite.NoError(err) - suite.NotNil(dbAttachment) - - suite.Equal(attachmentOwner, dbAttachment.AccountID) - suite.Equal(attachmentStatus, dbAttachment.StatusID) - suite.Equal(attachmentURL, dbAttachment.RemoteURL) - suite.NotEmpty(dbAttachment.URL) - suite.NotEmpty(dbAttachment.Blurhash) - suite.NotEmpty(dbAttachment.ID) - suite.NotEmpty(dbAttachment.CreatedAt) - suite.NotEmpty(dbAttachment.UpdatedAt) - suite.Equal(1.336546184738956, dbAttachment.FileMeta.Original.Aspect) - suite.Equal(2071680, dbAttachment.FileMeta.Original.Size) - suite.Equal(1245, dbAttachment.FileMeta.Original.Height) - suite.Equal(1664, dbAttachment.FileMeta.Original.Width) - suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", dbAttachment.Blurhash) - suite.Equal(gtsmodel.ProcessingStatusProcessed, dbAttachment.Processing) - suite.NotEmpty(dbAttachment.File.Path) - suite.Equal(attachmentContentType, dbAttachment.File.ContentType) - suite.Equal(attachmentDescription, dbAttachment.Description) - - suite.NotEmpty(dbAttachment.Thumbnail.Path) - suite.NotEmpty(dbAttachment.Type) -} - -func TestAttachmentTestSuite(t *testing.T) { - suite.Run(t, new(AttachmentTestSuite)) -} diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 4f977b8c8..d4786f62d 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -41,34 +41,7 @@ type Dereferencer interface { GetRemoteInstance(ctx context.Context, username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) - // GetRemoteAttachment takes a minimal attachment struct and converts it into a fully fleshed out attachment, stored in the database and instance storage. - // - // The parameter minAttachment must have at least the following fields defined: - // * minAttachment.RemoteURL - // * minAttachment.AccountID - // * minAttachment.File.ContentType - // - // The returned attachment will have an ID generated for it, so no need to generate one beforehand. - // A blurhash will also be generated for the attachment. - // - // Most other fields will be preserved on the passed attachment, including: - // * minAttachment.StatusID - // * minAttachment.CreatedAt - // * minAttachment.UpdatedAt - // * minAttachment.FileMeta - // * minAttachment.AccountID - // * minAttachment.Description - // * minAttachment.ScheduledStatusID - // * minAttachment.Thumbnail.RemoteURL - // * minAttachment.Avatar - // * minAttachment.Header - // - // GetRemoteAttachment will return early if an attachment with the same value as minAttachment.RemoteURL - // is found in the database -- then that attachment will be returned and nothing else will be changed or stored. - GetRemoteAttachment(ctx context.Context, requestingUsername string, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) - // RefreshAttachment is like GetRemoteAttachment, but the attachment will always be dereferenced again, - // whether or not it was already stored in the database. - RefreshAttachment(ctx context.Context, requestingUsername string, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) + GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string) (*media.Media, error) DereferenceAnnounce(ctx context.Context, announce *gtsmodel.Status, requestingUsername string) error DereferenceThread(ctx context.Context, username string, statusIRI *url.URL) error diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go new file mode 100644 index 000000000..4d62fe0a6 --- /dev/null +++ b/internal/federation/dereferencing/media.go @@ -0,0 +1,55 @@ +/* + 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 dereferencing + +import ( + "context" + "fmt" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string) (*media.Media, error) { + if accountID == "" { + return nil, fmt.Errorf("RefreshAttachment: minAttachment account ID was empty") + } + + t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) + if err != nil { + return nil, fmt.Errorf("RefreshAttachment: error creating transport: %s", err) + } + + derefURI, err := url.Parse(remoteURL) + if err != nil { + return nil, err + } + + data, err := t.DereferenceMedia(ctx, derefURI) + if err != nil { + return nil, fmt.Errorf("RefreshAttachment: error dereferencing media: %s", err) + } + + m, err := d.mediaManager.ProcessMedia(ctx, data, accountID, remoteURL) + if err != nil { + return nil, fmt.Errorf("RefreshAttachment: error processing attachment: %s", err) + } + + return m, nil +} diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go new file mode 100644 index 000000000..cc158c9a9 --- /dev/null +++ b/internal/federation/dereferencing/media_test.go @@ -0,0 +1,102 @@ +/* + 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 dereferencing_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type AttachmentTestSuite struct { + DereferencerStandardTestSuite +} + +func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { + ctx := context.Background() + + fetchingAccount := suite.testAccounts["local_account_1"] + + attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM" + attachmentStatus := "01FENS9NTTVNEX1YZV7GB63MT8" + attachmentContentType := "image/jpeg" + attachmentURL := "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg" + attachmentDescription := "It's a cute plushie." + + media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL) + suite.NoError(err) + + attachment, err := media.LoadAttachment(ctx) + suite.NoError(err) + + suite.NotNil(attachment) + + suite.Equal(attachmentOwner, attachment.AccountID) + suite.Equal(attachmentStatus, attachment.StatusID) + suite.Equal(attachmentURL, attachment.RemoteURL) + suite.NotEmpty(attachment.URL) + suite.NotEmpty(attachment.Blurhash) + suite.NotEmpty(attachment.ID) + suite.NotEmpty(attachment.CreatedAt) + suite.NotEmpty(attachment.UpdatedAt) + suite.Equal(1.336546184738956, attachment.FileMeta.Original.Aspect) + suite.Equal(2071680, attachment.FileMeta.Original.Size) + suite.Equal(1245, attachment.FileMeta.Original.Height) + suite.Equal(1664, attachment.FileMeta.Original.Width) + suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", attachment.Blurhash) + suite.Equal(gtsmodel.ProcessingStatusProcessed, attachment.Processing) + suite.NotEmpty(attachment.File.Path) + suite.Equal(attachmentContentType, attachment.File.ContentType) + suite.Equal(attachmentDescription, attachment.Description) + + suite.NotEmpty(attachment.Thumbnail.Path) + suite.NotEmpty(attachment.Type) + + // attachment should also now be in the database + dbAttachment, err := suite.db.GetAttachmentByID(context.Background(), attachment.ID) + suite.NoError(err) + suite.NotNil(dbAttachment) + + suite.Equal(attachmentOwner, dbAttachment.AccountID) + suite.Equal(attachmentStatus, dbAttachment.StatusID) + suite.Equal(attachmentURL, dbAttachment.RemoteURL) + suite.NotEmpty(dbAttachment.URL) + suite.NotEmpty(dbAttachment.Blurhash) + suite.NotEmpty(dbAttachment.ID) + suite.NotEmpty(dbAttachment.CreatedAt) + suite.NotEmpty(dbAttachment.UpdatedAt) + suite.Equal(1.336546184738956, dbAttachment.FileMeta.Original.Aspect) + suite.Equal(2071680, dbAttachment.FileMeta.Original.Size) + suite.Equal(1245, dbAttachment.FileMeta.Original.Height) + suite.Equal(1664, dbAttachment.FileMeta.Original.Width) + suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", dbAttachment.Blurhash) + suite.Equal(gtsmodel.ProcessingStatusProcessed, dbAttachment.Processing) + suite.NotEmpty(dbAttachment.File.Path) + suite.Equal(attachmentContentType, dbAttachment.File.ContentType) + suite.Equal(attachmentDescription, dbAttachment.Description) + + suite.NotEmpty(dbAttachment.Thumbnail.Path) + suite.NotEmpty(dbAttachment.Type) +} + +func TestAttachmentTestSuite(t *testing.T) { + suite.Run(t, new(AttachmentTestSuite)) +} diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index d7de5936a..e184b585f 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -393,9 +393,15 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. a.AccountID = status.AccountID a.StatusID = status.ID - attachment, err := d.GetRemoteAttachment(ctx, requestingUsername, a) + media, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL) if err != nil { - logrus.Errorf("populateStatusAttachments: couldn't get remote attachment %s: %s", a.RemoteURL, err) + logrus.Errorf("populateStatusAttachments: couldn't get remote media %s: %s", a.RemoteURL, err) + continue + } + + attachment, err := media.LoadAttachment(ctx) + if err != nil { + logrus.Errorf("populateStatusAttachments: couldn't load remote attachment %s: %s", a.RemoteURL, err) continue } diff --git a/internal/media/manager.go b/internal/media/manager.go index 8032ab42d..9ca450141 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -37,7 +37,17 @@ import ( // Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs. type Manager interface { - ProcessMedia(ctx context.Context, data []byte, accountID string) (*Media, error) + // ProcessMedia begins the process of decoding and storing the given data as a piece of media (aka an attachment). + // It will return a pointer to a Media struct upon which further actions can be performed, such as getting + // the finished media, thumbnail, decoded bytes, attachment, and setting additional fields. + // + // accountID should be the account that the media belongs to. + // + // RemoteURL is optional, and can be an empty string. Setting this to a non-empty string indicates that + // the piece of media originated on a remote instance and has been dereferenced to be cached locally. + ProcessMedia(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) + + ProcessEmoji(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) } type manager struct { @@ -70,7 +80,7 @@ func New(database db.DB, storage *kv.KVStore) (Manager, error) { INTERFACE FUNCTIONS */ -func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string) (*Media, error) { +func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) { contentType, err := parseContentType(data) if err != nil { return nil, err @@ -85,7 +95,7 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin switch mainType { case mimeImage: - media, err := m.preProcessImage(ctx, data, contentType, accountID) + media, err := m.preProcessImage(ctx, data, contentType, accountID, remoteURL) if err != nil { return nil, err } @@ -97,7 +107,7 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin return default: // start preloading the media for the caller's convenience - media.PreLoad(innerCtx) + media.preLoad(innerCtx) } }) @@ -107,8 +117,12 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin } } +func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) { + return nil, nil +} + // preProcessImage initializes processing -func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string) (*Media, error) { +func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, remoteURL string) (*Media, error) { if !supportedImage(contentType) { return nil, fmt.Errorf("image type %s not supported", contentType) } @@ -128,6 +142,7 @@ func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType ID: id, UpdatedAt: time.Now(), URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), + RemoteURL: remoteURL, Type: gtsmodel.FileTypeImage, AccountID: accountID, Processing: 0, diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go new file mode 100644 index 000000000..45428fbba --- /dev/null +++ b/internal/media/manager_test.go @@ -0,0 +1,4 @@ +package media_test + + + diff --git a/internal/media/media.go b/internal/media/media.go index 022de063e..e19997391 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -1,9 +1,28 @@ +/* + 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 media import ( "context" "fmt" "sync" + "time" "codeberg.org/gruf/go-store/kv" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -26,7 +45,8 @@ type Media struct { attachment will be updated incrementally as media goes through processing */ - attachment *gtsmodel.MediaAttachment + attachment *gtsmodel.MediaAttachment // will only be set if the media is an attachment + emoji *gtsmodel.Emoji // will only be set if the media is an emoji rawData []byte /* @@ -86,17 +106,10 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { m.attachment.Thumbnail.FileSize = thumb.size // put or update the attachment in the database - if err := m.database.Put(ctx, m.attachment); err != nil { - if err != db.ErrAlreadyExists { - m.err = fmt.Errorf("error putting attachment: %s", err) - m.thumbstate = errored - return nil, m.err - } - if err := m.database.UpdateByPrimaryKey(ctx, m.attachment); err != nil { - m.err = fmt.Errorf("error updating attachment: %s", err) - m.thumbstate = errored - return nil, m.err - } + if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { + m.err = err + m.thumbstate = errored + return nil, err } // set the thumbnail of this media @@ -148,6 +161,30 @@ func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { return nil, err } + // put the full size in storage + if err := m.storage.Put(m.attachment.File.Path, decoded.image); err != nil { + m.err = fmt.Errorf("error storing full size image: %s", err) + m.fullSizeState = errored + return nil, m.err + } + + // set appropriate fields on the attachment based on the image we derived + m.attachment.FileMeta.Original = gtsmodel.Original{ + Width: decoded.width, + Height: decoded.height, + Size: decoded.size, + Aspect: decoded.aspect, + } + m.attachment.File.FileSize = decoded.size + m.attachment.File.UpdatedAt = time.Now() + + // put or update the attachment in the database + if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { + m.err = err + m.fullSizeState = errored + return nil, err + } + // set the fullsize of this media m.fullSize = decoded @@ -163,17 +200,46 @@ func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { return nil, fmt.Errorf("full size processing status %d unknown", m.fullSizeState) } -// PreLoad begins the process of deriving the thumbnail and encoding the full-size image. +func (m *Media) SetAsAvatar(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.attachment.Avatar = true + return putOrUpdateAttachment(ctx, m.database, m.attachment) +} + +func (m *Media) SetAsHeader(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.attachment.Header = true + return putOrUpdateAttachment(ctx, m.database, m.attachment) +} + +func (m *Media) SetStatusID(ctx context.Context, statusID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.attachment.StatusID = statusID + return putOrUpdateAttachment(ctx, m.database, m.attachment) +} + +// AttachmentID returns the ID of the underlying media attachment without blocking processing. +func (m *Media) AttachmentID() string { + return m.attachment.ID +} + +// preLoad begins the process of deriving the thumbnail and encoding the full-size image. // It does this in a non-blocking way, so you can call it and then come back later and check // if it's finished. -func (m *Media) PreLoad(ctx context.Context) { +func (m *Media) preLoad(ctx context.Context) { go m.Thumb(ctx) go m.FullSize(ctx) } // Load is the blocking equivalent of pre-load. It makes sure the thumbnail and full-size image // have been processed, then it returns the full-size image. -func (m *Media) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { +func (m *Media) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { if _, err := m.Thumb(ctx); err != nil { return nil, err } @@ -184,3 +250,20 @@ func (m *Media) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { return m.attachment, nil } + +func (m *Media) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { + return nil, nil +} + +func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { + if err := database.Put(ctx, attachment); err != nil { + if err != db.ErrAlreadyExists { + return fmt.Errorf("putOrUpdateAttachment: proper error while putting attachment: %s", err) + } + if err := database.UpdateByPrimaryKey(ctx, attachment); err != nil { + return fmt.Errorf("putOrUpdateAttachment: error while updating attachment: %s", err) + } + } + + return nil +} diff --git a/internal/media/media_test.go b/internal/media/media_test.go new file mode 100644 index 000000000..7e820c9ea --- /dev/null +++ b/internal/media/media_test.go @@ -0,0 +1,65 @@ +/* + 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 media_test + +import ( + "testing" + + "codeberg.org/gruf/go-store/kv" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MediaStandardTestSuite struct { + suite.Suite + + db db.DB + storage *kv.KVStore + manager media.Manager +} + +func (suite *MediaStandardTestSuite) SetupSuite() { + testrig.InitTestLog() + testrig.InitTestConfig() + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() +} + +func (suite *MediaStandardTestSuite) SetupTest() { + testrig.StandardStorageSetup(suite.storage, "../../testrig/media") + testrig.StandardDBSetup(suite.db, nil) + + m, err := media.New(suite.db, suite.storage) + if err != nil { + panic(err) + } + suite.manager = m +} + +func (suite *MediaStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func TestMediaStandardTestSuite(t *testing.T) { + suite.Run(t, &MediaStandardTestSuite{}) +} diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 8de6c83f0..6e74a0ccd 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -33,7 +33,6 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -140,31 +139,40 @@ func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHead var err error maxImageSize := viper.GetInt(config.Keys.MediaImageMaxSize) if int(avatar.Size) > maxImageSize { - err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) + err = fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) return nil, err } f, err := avatar.Open() if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) + return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) } // extract the bytes buf := new(bytes.Buffer) size, err := io.Copy(buf, f) if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) + return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) } if size == 0 { - return nil, errors.New("could not read provided avatar: size 0 bytes") + return nil, errors.New("UpdateAvatar: could not read provided avatar: size 0 bytes") + } + + // we're done with the FileHeader now + if err := f.Close(); err != nil { + return nil, fmt.Errorf("UpdateAvatar: error closing multipart fileheader: %s", err) } // do the setting - avatarInfo, err := p.mediaManager.ProcessHeaderOrAvatar(ctx, buf.Bytes(), accountID, media.TypeAvatar, "") + media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, "") if err != nil { - return nil, fmt.Errorf("error processing avatar: %s", err) + return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err) + } + + if err := media.SetAsAvatar(ctx); err != nil { + return nil, fmt.Errorf("UpdateAvatar: error setting media as avatar: %s", err) } - return avatarInfo, f.Close() + return media.LoadAttachment(ctx) } // UpdateHeader does the dirty work of checking the header part of an account update form, @@ -174,31 +182,40 @@ func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHead var err error maxImageSize := viper.GetInt(config.Keys.MediaImageMaxSize) if int(header.Size) > maxImageSize { - err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) + err = fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) return nil, err } f, err := header.Open() if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) + return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) } // extract the bytes buf := new(bytes.Buffer) size, err := io.Copy(buf, f) if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) + return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) } if size == 0 { - return nil, errors.New("could not read provided header: size 0 bytes") + return nil, errors.New("UpdateHeader: could not read provided header: size 0 bytes") + } + + // we're done with the FileHeader now + if err := f.Close(); err != nil { + return nil, fmt.Errorf("UpdateHeader: error closing multipart fileheader: %s", err) } // do the setting - headerInfo, err := p.mediaManager.ProcessHeaderOrAvatar(ctx, buf.Bytes(), accountID, media.TypeHeader, "") + media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, "") if err != nil { - return nil, fmt.Errorf("error processing header: %s", err) + return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err) + } + + if err := media.SetAsHeader(ctx); err != nil { + return nil, fmt.Errorf("UpdateHeader: error setting media as header: %s", err) } - return headerInfo, f.Close() + return media.LoadAttachment(ctx) } func (p *processor) processNote(ctx context.Context, note string, accountID string) (string, error) { diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index 5620374b8..6fb2ca8c5 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -27,7 +27,6 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" ) func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { @@ -49,26 +48,20 @@ func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, return nil, errors.New("could not read provided emoji: size 0 bytes") } - // allow the mediaManager to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using - emoji, err := p.mediaManager.ProcessLocalEmoji(ctx, buf.Bytes(), form.Shortcode) + media, err := p.mediaManager.ProcessEmoji(ctx, buf.Bytes(), account.ID, "") if err != nil { - return nil, fmt.Errorf("error reading emoji: %s", err) + return nil, err } - emojiID, err := id.NewULID() + emoji, err := media.LoadEmoji(ctx) if err != nil { return nil, err } - emoji.ID = emojiID apiEmoji, err := p.tc.EmojiToAPIEmoji(ctx, emoji) if err != nil { return nil, fmt.Errorf("error converting emoji to apitype: %s", err) } - if err := p.db.Put(ctx, emoji); err != nil { - return nil, fmt.Errorf("database error while processing emoji: %s", err) - } - return &apiEmoji, nil } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 357278e64..d1840196a 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -44,13 +44,13 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, errors.New("could not read provided attachment: size 0 bytes") } - // process the media and load it immediately - media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID) + // process the media attachment and load it immediately + media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID, "") if err != nil { return nil, err } - attachment, err := media.Load(ctx) + attachment, err := media.LoadAttachment(ctx) if err != nil { return nil, err } @@ -62,10 +62,5 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err) } - // now we can confidently put the attachment in the database - if err := p.db.Put(ctx, attachment); err != nil { - return nil, fmt.Errorf("error storing media attachment in db: %s", err) - } - return &apiAttachment, nil } diff --git a/internal/transport/derefmedia.go b/internal/transport/derefmedia.go index 8a6aa4e24..3fa4a89e4 100644 --- a/internal/transport/derefmedia.go +++ b/internal/transport/derefmedia.go @@ -28,18 +28,15 @@ import ( "github.com/sirupsen/logrus" ) -func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL, expectedContentType string) ([]byte, error) { +func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, error) { l := logrus.WithField("func", "DereferenceMedia") l.Debugf("performing GET to %s", iri.String()) req, err := http.NewRequestWithContext(ctx, "GET", iri.String(), nil) if err != nil { return nil, err } - if expectedContentType == "" { - req.Header.Add("Accept", "*/*") - } else { - req.Header.Add("Accept", expectedContentType) - } + + req.Header.Add("Accept", "*/*") // we don't know what kind of media we're going to get here req.Header.Add("Date", t.clock.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT") req.Header.Add("User-Agent", fmt.Sprintf("%s %s", t.appAgent, t.gofedAgent)) req.Header.Set("Host", iri.Host) diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 73b015865..b470b289a 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -34,7 +34,7 @@ import ( type Transport interface { pub.Transport // DereferenceMedia fetches the bytes of the given media attachment IRI, with the expectedContentType. - DereferenceMedia(ctx context.Context, iri *url.URL, expectedContentType string) ([]byte, error) + DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, error) // DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo. DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error) // Finger performs a webfinger request with the given username and domain, and returns the bytes from the response body. diff --git a/testrig/mediahandler.go b/testrig/mediahandler.go index ba2148655..046ddc5be 100644 --- a/testrig/mediahandler.go +++ b/testrig/mediahandler.go @@ -26,5 +26,9 @@ import ( // NewTestMediaManager returns a media handler with the default test config, and the given db and storage. func NewTestMediaManager(db db.DB, storage *kv.KVStore) media.Manager { - return media.New(db, storage) + m, err := media.New(db, storage) + if err != nil { + panic(err) + } + return m } -- cgit v1.2.3 From dccf21dd87638320a687a0556c973cced541c945 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Sun, 9 Jan 2022 18:41:22 +0100 Subject: tests are passing, but there's still much to be done --- cmd/gotosocial/action/server/server.go | 5 +- internal/ap/extract.go | 18 ++--- internal/ap/extractattachments_test.go | 2 +- internal/ap/interfaces.go | 8 ++- internal/federation/dereferencing/account.go | 25 +++---- internal/federation/dereferencing/dereferencer.go | 2 +- internal/federation/dereferencing/media.go | 4 +- internal/federation/dereferencing/media_test.go | 17 +++-- internal/federation/dereferencing/status.go | 9 ++- internal/media/image.go | 32 +++++---- internal/media/manager.go | 80 +++++++++++++++++++---- internal/media/manager_test.go | 50 ++++++++++++++ internal/media/media.go | 51 ++++++--------- internal/media/media_test.go | 65 ------------------ internal/media/types.go | 26 ++++++++ internal/processing/account/update.go | 23 ++++--- internal/processing/admin/emoji.go | 2 +- internal/processing/media/create.go | 12 +++- 18 files changed, 260 insertions(+), 171 deletions(-) delete mode 100644 internal/media/media_test.go (limited to 'internal/processing/media/create.go') diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index 05c2e8974..8a227edb7 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -105,7 +105,10 @@ var Start action.GTSAction = func(ctx context.Context) error { } // build backend handlers - mediaManager := media.New(dbService, storage) + mediaManager, err := media.New(dbService, storage) + if err != nil { + return fmt.Errorf("error creating media manager: %s", err) + } oauthServer := oauth.New(ctx, dbService) transportController := transport.NewController(dbService, &federation.Clock{}, http.DefaultClient) federator := federation.NewFederator(dbService, federatingDB, transportController, typeConverter, mediaManager) diff --git a/internal/ap/extract.go b/internal/ap/extract.go index ed61faf1e..49dac7186 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -395,20 +395,20 @@ func ExtractAttachment(i Attachmentable) (*gtsmodel.MediaAttachment, error) { attachment.Description = name } + attachment.Blurhash = ExtractBlurhash(i) + attachment.Processing = gtsmodel.ProcessingStatusReceived return attachment, nil } -// func extractBlurhash(i withBlurhash) (string, error) { -// if i.GetTootBlurhashProperty() == nil { -// return "", errors.New("blurhash property was nil") -// } -// if i.GetTootBlurhashProperty().Get() == "" { -// return "", errors.New("empty blurhash string") -// } -// return i.GetTootBlurhashProperty().Get(), nil -// } +// ExtractBlurhash extracts the blurhash value (if present) from a WithBlurhash interface. +func ExtractBlurhash(i WithBlurhash) string { + if i.GetTootBlurhash() == nil { + return "" + } + return i.GetTootBlurhash().Get() +} // ExtractHashtags returns a slice of tags on the interface. func ExtractHashtags(i WithTag) ([]*gtsmodel.Tag, error) { diff --git a/internal/ap/extractattachments_test.go b/internal/ap/extractattachments_test.go index 3cee98faa..b937911d2 100644 --- a/internal/ap/extractattachments_test.go +++ b/internal/ap/extractattachments_test.go @@ -42,7 +42,7 @@ func (suite *ExtractAttachmentsTestSuite) TestExtractAttachments() { suite.Equal("image/jpeg", attachment1.File.ContentType) suite.Equal("https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg", attachment1.RemoteURL) suite.Equal("It's a cute plushie.", attachment1.Description) - suite.Empty(attachment1.Blurhash) // atm we discard blurhashes and generate them ourselves during processing + suite.Equal("UxQ0EkRP_4tRxtRjWBt7%hozM_ayV@oLf6WB", attachment1.Blurhash) } func (suite *ExtractAttachmentsTestSuite) TestExtractNoAttachments() { diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 582465ec3..6edaa42ba 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -70,6 +70,7 @@ type Attachmentable interface { WithMediaType WithURL WithName + WithBlurhash } // Hashtaggable represents the minimum activitypub interface for representing a 'hashtag' tag. @@ -284,9 +285,10 @@ type WithMediaType interface { GetActivityStreamsMediaType() vocab.ActivityStreamsMediaTypeProperty } -// type withBlurhash interface { -// GetTootBlurhashProperty() vocab.TootBlurhashProperty -// } +// WithBlurhash represents an activity with TootBlurhashProperty +type WithBlurhash interface { + GetTootBlurhash() vocab.TootBlurhashProperty +} // type withFocalPoint interface { // // TODO diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 5912ff29a..d83fc3bac 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -32,6 +32,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/transport" ) @@ -256,16 +257,16 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - media, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, targetAccount.AvatarRemoteURL) + avatar := true + processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ + RemoteURL: &targetAccount.AvatarRemoteURL, + Avatar: &avatar, + }) if err != nil { return err } - if err := media.SetAsAvatar(ctx); err != nil { - return err - } - - targetAccount.AvatarMediaAttachmentID = media.AttachmentID() + targetAccount.AvatarMediaAttachmentID = processingMedia.AttachmentID() } if targetAccount.HeaderRemoteURL != "" && (targetAccount.HeaderMediaAttachmentID == "" || refresh) { @@ -279,16 +280,16 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - media, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, targetAccount.HeaderRemoteURL) + header := true + processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ + RemoteURL: &targetAccount.HeaderRemoteURL, + Header: &header, + }) if err != nil { return err } - if err := media.SetAsHeader(ctx); err != nil { - return err - } - - targetAccount.HeaderMediaAttachmentID = media.AttachmentID() + targetAccount.HeaderMediaAttachmentID = processingMedia.AttachmentID() } return nil } diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index d4786f62d..787a39739 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -41,7 +41,7 @@ type Dereferencer interface { GetRemoteInstance(ctx context.Context, username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) - GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string) (*media.Media, error) + GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Media, error) DereferenceAnnounce(ctx context.Context, announce *gtsmodel.Status, requestingUsername string) error DereferenceThread(ctx context.Context, username string, statusIRI *url.URL) error diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index 4d62fe0a6..0ddab7ae0 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -26,7 +26,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/media" ) -func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string) (*media.Media, error) { +func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Media, error) { if accountID == "" { return nil, fmt.Errorf("RefreshAttachment: minAttachment account ID was empty") } @@ -46,7 +46,7 @@ func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, a return nil, fmt.Errorf("RefreshAttachment: error dereferencing media: %s", err) } - m, err := d.mediaManager.ProcessMedia(ctx, data, accountID, remoteURL) + m, err := d.mediaManager.ProcessMedia(ctx, data, accountID, ai) if err != nil { return nil, fmt.Errorf("RefreshAttachment: error processing attachment: %s", err) } diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go index cc158c9a9..8fb28d196 100644 --- a/internal/federation/dereferencing/media_test.go +++ b/internal/federation/dereferencing/media_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" ) type AttachmentTestSuite struct { @@ -32,7 +33,7 @@ type AttachmentTestSuite struct { func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { ctx := context.Background() - + fetchingAccount := suite.testAccounts["local_account_1"] attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM" @@ -40,8 +41,14 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { attachmentContentType := "image/jpeg" attachmentURL := "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg" attachmentDescription := "It's a cute plushie." - - media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL) + attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" + + media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalInfo{ + StatusID: &attachmentStatus, + RemoteURL: &attachmentURL, + Description: &attachmentDescription, + Blurhash: &attachmentBlurhash, + }) suite.NoError(err) attachment, err := media.LoadAttachment(ctx) @@ -61,7 +68,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { suite.Equal(2071680, attachment.FileMeta.Original.Size) suite.Equal(1245, attachment.FileMeta.Original.Height) suite.Equal(1664, attachment.FileMeta.Original.Width) - suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", attachment.Blurhash) + suite.Equal(attachmentBlurhash, attachment.Blurhash) suite.Equal(gtsmodel.ProcessingStatusProcessed, attachment.Processing) suite.NotEmpty(attachment.File.Path) suite.Equal(attachmentContentType, attachment.File.ContentType) @@ -87,7 +94,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { suite.Equal(2071680, dbAttachment.FileMeta.Original.Size) suite.Equal(1245, dbAttachment.FileMeta.Original.Height) suite.Equal(1664, dbAttachment.FileMeta.Original.Width) - suite.Equal("LwP?p=aK_4%N%MRjWXt7%hozM_a}", dbAttachment.Blurhash) + suite.Equal(attachmentBlurhash, dbAttachment.Blurhash) suite.Equal(gtsmodel.ProcessingStatusProcessed, dbAttachment.Processing) suite.NotEmpty(dbAttachment.File.Path) suite.Equal(attachmentContentType, dbAttachment.File.ContentType) diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index e184b585f..47ce087a2 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -32,6 +32,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/media" ) // EnrichRemoteStatus takes a status that's already been inserted into the database in a minimal form, @@ -393,7 +394,13 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. a.AccountID = status.AccountID a.StatusID = status.ID - media, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL) + media, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL, &media.AdditionalInfo{ + CreatedAt: &a.CreatedAt, + StatusID: &a.StatusID, + RemoteURL: &a.RemoteURL, + Description: &a.Description, + Blurhash: &a.Blurhash, + }) if err != nil { logrus.Errorf("populateStatusAttachments: couldn't get remote media %s: %s", a.RemoteURL, err) continue diff --git a/internal/media/image.go b/internal/media/image.go index acc62a28b..4c0b28c02 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -108,7 +108,7 @@ func decodeImage(b []byte, contentType string) (*ImageMeta, error) { // // Note that the aspect ratio of the image will be retained, // so it will not necessarily be a square, even if x and y are set as the same value. -func deriveThumbnail(b []byte, contentType string) (*ImageMeta, error) { +func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageMeta, error) { var i image.Image var err error @@ -138,10 +138,20 @@ func deriveThumbnail(b []byte, contentType string) (*ImageMeta, error) { size := width * height aspect := float64(width) / float64(height) - tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor) - bh, err := blurhash.Encode(4, 3, tiny) - if err != nil { - return nil, err + im := &ImageMeta{ + width: width, + height: height, + size: size, + aspect: aspect, + } + + if createBlurhash { + tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor) + bh, err := blurhash.Encode(4, 3, tiny) + if err != nil { + return nil, err + } + im.blurhash = bh } out := &bytes.Buffer{} @@ -150,14 +160,10 @@ func deriveThumbnail(b []byte, contentType string) (*ImageMeta, error) { }); err != nil { return nil, err } - return &ImageMeta{ - image: out.Bytes(), - width: width, - height: height, - size: size, - aspect: aspect, - blurhash: bh, - }, nil + + im.image = out.Bytes() + + return im, nil } // deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. diff --git a/internal/media/manager.go b/internal/media/manager.go index 9ca450141..5e62b39b2 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -45,9 +45,9 @@ type Manager interface { // // RemoteURL is optional, and can be an empty string. Setting this to a non-empty string indicates that // the piece of media originated on a remote instance and has been dereferenced to be cached locally. - ProcessMedia(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) + ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Media, error) - ProcessEmoji(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) + ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Media, error) } type manager struct { @@ -80,7 +80,7 @@ func New(database db.DB, storage *kv.KVStore) (Manager, error) { INTERFACE FUNCTIONS */ -func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) { +func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Media, error) { contentType, err := parseContentType(data) if err != nil { return nil, err @@ -95,7 +95,7 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin switch mainType { case mimeImage: - media, err := m.preProcessImage(ctx, data, contentType, accountID, remoteURL) + media, err := m.preProcessImage(ctx, data, contentType, accountID, ai) if err != nil { return nil, err } @@ -117,12 +117,12 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin } } -func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string, remoteURL string) (*Media, error) { +func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Media, error) { return nil, nil } // preProcessImage initializes processing -func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, remoteURL string) (*Media, error) { +func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, ai *AdditionalInfo) (*Media, error) { if !supportedImage(contentType) { return nil, fmt.Errorf("image type %s not supported", contentType) } @@ -139,13 +139,24 @@ func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType extension := strings.Split(contentType, "/")[1] attachment := >smodel.MediaAttachment{ - ID: id, - UpdatedAt: time.Now(), - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), - RemoteURL: remoteURL, - Type: gtsmodel.FileTypeImage, - AccountID: accountID, - Processing: 0, + ID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StatusID: "", + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), + RemoteURL: "", + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: "", + Processing: 0, File: gtsmodel.File{ Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), ContentType: contentType, @@ -161,6 +172,49 @@ func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType Header: false, } + // check if we have additional info to add to the attachment + if ai != nil { + if ai.CreatedAt != nil { + attachment.CreatedAt = *ai.CreatedAt + } + + if ai.StatusID != nil { + attachment.StatusID = *ai.StatusID + } + + if ai.RemoteURL != nil { + attachment.RemoteURL = *ai.RemoteURL + } + + if ai.Description != nil { + attachment.Description = *ai.Description + } + + if ai.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *ai.ScheduledStatusID + } + + if ai.Blurhash != nil { + attachment.Blurhash = *ai.Blurhash + } + + if ai.Avatar != nil { + attachment.Avatar = *ai.Avatar + } + + if ai.Header != nil { + attachment.Header = *ai.Header + } + + if ai.FocusX != nil { + attachment.FileMeta.Focus.X = *ai.FocusX + } + + if ai.FocusY != nil { + attachment.FileMeta.Focus.Y = *ai.FocusY + } + } + media := &Media{ attachment: attachment, rawData: data, diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 45428fbba..aad7f46d0 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -1,4 +1,54 @@ +/* + 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 media_test +import ( + "codeberg.org/gruf/go-store/kv" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MediaManagerStandardTestSuite struct { + suite.Suite + + db db.DB + storage *kv.KVStore + manager media.Manager +} + +func (suite *MediaManagerStandardTestSuite) SetupSuite() { + testrig.InitTestLog() + testrig.InitTestConfig() + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() +} +func (suite *MediaManagerStandardTestSuite) SetupTest() { + testrig.StandardStorageSetup(suite.storage, "../../testrig/media") + testrig.StandardDBSetup(suite.db, nil) + suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage) +} +func (suite *MediaManagerStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/media/media.go b/internal/media/media.go index e19997391..e0cfe09b7 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -47,7 +47,8 @@ type Media struct { attachment *gtsmodel.MediaAttachment // will only be set if the media is an attachment emoji *gtsmodel.Emoji // will only be set if the media is an emoji - rawData []byte + + rawData []byte /* below fields represent the processing state of the media thumbnail @@ -81,7 +82,15 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { switch m.thumbstate { case received: // we haven't processed a thumbnail for this media yet so do it now - thumb, err := deriveThumbnail(m.rawData, m.attachment.File.ContentType) + + // check if we need to create a blurhash or if there's already one set + var createBlurhash bool + if m.attachment.Blurhash == "" { + // no blurhash created yet + createBlurhash = true + } + + thumb, err := deriveThumbnail(m.rawData, m.attachment.File.ContentType, createBlurhash) if err != nil { m.err = fmt.Errorf("error deriving thumbnail: %s", err) m.thumbstate = errored @@ -96,7 +105,10 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { } // set appropriate fields on the attachment based on the thumbnail we derived - m.attachment.Blurhash = thumb.blurhash + if createBlurhash { + m.attachment.Blurhash = thumb.blurhash + } + m.attachment.FileMeta.Small = gtsmodel.Small{ Width: thumb.width, Height: thumb.height, @@ -105,7 +117,6 @@ func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { } m.attachment.Thumbnail.FileSize = thumb.size - // put or update the attachment in the database if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { m.err = err m.thumbstate = errored @@ -177,8 +188,8 @@ func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { } m.attachment.File.FileSize = decoded.size m.attachment.File.UpdatedAt = time.Now() + m.attachment.Processing = gtsmodel.ProcessingStatusProcessed - // put or update the attachment in the database if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { m.err = err m.fullSizeState = errored @@ -200,30 +211,6 @@ func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { return nil, fmt.Errorf("full size processing status %d unknown", m.fullSizeState) } -func (m *Media) SetAsAvatar(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - - m.attachment.Avatar = true - return putOrUpdateAttachment(ctx, m.database, m.attachment) -} - -func (m *Media) SetAsHeader(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - - m.attachment.Header = true - return putOrUpdateAttachment(ctx, m.database, m.attachment) -} - -func (m *Media) SetStatusID(ctx context.Context, statusID string) error { - m.mu.Lock() - defer m.mu.Unlock() - - m.attachment.StatusID = statusID - return putOrUpdateAttachment(ctx, m.database, m.attachment) -} - // AttachmentID returns the ID of the underlying media attachment without blocking processing. func (m *Media) AttachmentID() string { return m.attachment.ID @@ -237,8 +224,8 @@ func (m *Media) preLoad(ctx context.Context) { go m.FullSize(ctx) } -// Load is the blocking equivalent of pre-load. It makes sure the thumbnail and full-size image -// have been processed, then it returns the full-size image. +// Load is the blocking equivalent of pre-load. It makes sure the thumbnail and full-size +// image have been processed, then it returns the completed attachment. func (m *Media) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { if _, err := m.Thumb(ctx); err != nil { return nil, err @@ -255,6 +242,8 @@ func (m *Media) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { return nil, nil } +// putOrUpdateAttachment is just a convenience function for first trying to PUT the attachment in the database, +// and then if that doesn't work because the attachment already exists, updating it instead. func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { if err := database.Put(ctx, attachment); err != nil { if err != db.ErrAlreadyExists { diff --git a/internal/media/media_test.go b/internal/media/media_test.go deleted file mode 100644 index 7e820c9ea..000000000 --- a/internal/media/media_test.go +++ /dev/null @@ -1,65 +0,0 @@ -/* - 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 media_test - -import ( - "testing" - - "codeberg.org/gruf/go-store/kv" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type MediaStandardTestSuite struct { - suite.Suite - - db db.DB - storage *kv.KVStore - manager media.Manager -} - -func (suite *MediaStandardTestSuite) SetupSuite() { - testrig.InitTestLog() - testrig.InitTestConfig() - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() -} - -func (suite *MediaStandardTestSuite) SetupTest() { - testrig.StandardStorageSetup(suite.storage, "../../testrig/media") - testrig.StandardDBSetup(suite.db, nil) - - m, err := media.New(suite.db, suite.storage) - if err != nil { - panic(err) - } - suite.manager = m -} - -func (suite *MediaStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func TestMediaStandardTestSuite(t *testing.T) { - suite.Run(t, &MediaStandardTestSuite{}) -} diff --git a/internal/media/types.go b/internal/media/types.go index d40f402d2..aaf423682 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -22,6 +22,7 @@ import ( "bytes" "errors" "fmt" + "time" "github.com/h2non/filetype" ) @@ -67,6 +68,31 @@ const ( TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests ) +// AdditionalInfo represents additional information that should be added to an attachment +// when processing a piece of media. +type AdditionalInfo struct { + // Time that this media was created; defaults to time.Now(). + CreatedAt *time.Time + // ID of the status to which this media is attached; defaults to "". + StatusID *string + // URL of the media on a remote instance; defaults to "". + RemoteURL *string + // Image description of this media; defaults to "". + Description *string + // Blurhash of this media; defaults to "". + Blurhash *string + // ID of the scheduled status to which this media is attached; defaults to "". + ScheduledStatusID *string + // Mark this media as in-use as an avatar; defaults to false. + Avatar *bool + // Mark this media as in-use as a header; defaults to false. + Header *bool + // X focus coordinate for this media; defaults to 0. + FocusX *float32 + // Y focus coordinate for this media; defaults to 0. + FocusY *float32 +} + // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). // Returns an error if the content type is not something we can process. func parseContentType(content []byte) (string, error) { diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 6e74a0ccd..e0dd493e1 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -33,6 +33,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -163,16 +164,15 @@ func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHead } // do the setting - media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, "") + isAvatar := true + processingMedia, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, &media.AdditionalInfo{ + Avatar: &isAvatar, + }) if err != nil { return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err) } - if err := media.SetAsAvatar(ctx); err != nil { - return nil, fmt.Errorf("UpdateAvatar: error setting media as avatar: %s", err) - } - - return media.LoadAttachment(ctx) + return processingMedia.LoadAttachment(ctx) } // UpdateHeader does the dirty work of checking the header part of an account update form, @@ -206,16 +206,15 @@ func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHead } // do the setting - media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, "") + isHeader := true + processingMedia, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, &media.AdditionalInfo{ + Header: &isHeader, + }) if err != nil { return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err) } - if err := media.SetAsHeader(ctx); err != nil { - return nil, fmt.Errorf("UpdateHeader: error setting media as header: %s", err) - } - - return media.LoadAttachment(ctx) + return processingMedia.LoadAttachment(ctx) } func (p *processor) processNote(ctx context.Context, note string, accountID string) (string, error) { diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index 6fb2ca8c5..737a4ebe2 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -48,7 +48,7 @@ func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, return nil, errors.New("could not read provided emoji: size 0 bytes") } - media, err := p.mediaManager.ProcessEmoji(ctx, buf.Bytes(), account.ID, "") + media, err := p.mediaManager.ProcessEmoji(ctx, buf.Bytes(), account.ID) if err != nil { return nil, err } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index d1840196a..093a3d2be 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -27,6 +27,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" ) func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { @@ -44,8 +45,17 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, errors.New("could not read provided attachment: size 0 bytes") } + focusX, focusY, err := parseFocus(form.Focus) + if err != nil { + return nil, fmt.Errorf("could not parse focus value %s: %s", form.Focus, err) + } + // process the media attachment and load it immediately - media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID, "") + media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID, &media.AdditionalInfo{ + Description: &form.Description, + FocusX: &focusX, + FocusY: &focusY, + }) if err != nil { return nil, err } -- cgit v1.2.3 From e0f9323b9aa98b55f3557086f7b0a17047943f39 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Mon, 10 Jan 2022 18:36:09 +0100 Subject: test the media manager a bit, add shutdown logic --- cmd/gotosocial/action/server/server.go | 4 +- cmd/gotosocial/action/testrig/testrig.go | 7 +- internal/api/client/account/account_test.go | 21 +- internal/api/client/fileserver/servefile_test.go | 4 +- .../api/client/followrequest/followrequest_test.go | 17 +- internal/api/client/media/mediacreate_test.go | 6 +- internal/api/client/status/status_test.go | 19 +- internal/api/client/user/user_test.go | 19 +- internal/api/s2s/user/inboxpost_test.go | 16 +- internal/api/s2s/user/outboxget_test.go | 12 +- internal/api/s2s/user/repliesget_test.go | 12 +- internal/api/s2s/user/user_test.go | 7 +- internal/api/s2s/user/userget_test.go | 4 +- internal/api/s2s/webfinger/webfinger_test.go | 7 +- internal/db/bundb/trace.go | 9 - internal/federation/dereferencing/account.go | 6 +- internal/federation/dereferencing/dereferencer.go | 2 +- internal/federation/dereferencing/media.go | 6 +- internal/federation/dereferencing/media_test.go | 4 +- internal/federation/dereferencing/status.go | 2 +- internal/gotosocial/gotosocial.go | 26 ++- internal/media/image.go | 156 +++++++++++-- internal/media/manager.go | 207 ++++++----------- internal/media/manager_test.go | 242 +++++++++++++++++-- internal/media/media.go | 258 --------------------- internal/media/media_test.go | 54 +++++ internal/media/processing.go | 256 ++++++++++++++++++++ internal/media/test/test-corrupted.jpg | 1 - internal/media/test/test-jpeg-blurhash.jpg | Bin 8802 -> 0 bytes internal/media/test/test-with-exif.jpg | Bin 1227452 -> 0 bytes internal/media/test/test-without-exif.jpg | Bin 1227452 -> 0 bytes internal/processing/account/account_test.go | 2 +- internal/processing/account/update.go | 4 +- internal/processing/media/create.go | 2 +- internal/processing/processor_test.go | 6 +- testrig/federator.go | 5 +- testrig/mediahandler.go | 2 +- testrig/processor.go | 5 +- 38 files changed, 872 insertions(+), 538 deletions(-) delete mode 100644 internal/media/media.go create mode 100644 internal/media/media_test.go create mode 100644 internal/media/processing.go delete mode 100644 internal/media/test/test-corrupted.jpg delete mode 100644 internal/media/test/test-jpeg-blurhash.jpg delete mode 100644 internal/media/test/test-with-exif.jpg delete mode 100644 internal/media/test/test-without-exif.jpg (limited to 'internal/processing/media/create.go') diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index 8a227edb7..5c1192b56 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -105,7 +105,7 @@ var Start action.GTSAction = func(ctx context.Context) error { } // build backend handlers - mediaManager, err := media.New(dbService, storage) + mediaManager, err := media.NewManager(dbService, storage) if err != nil { return fmt.Errorf("error creating media manager: %s", err) } @@ -203,7 +203,7 @@ var Start action.GTSAction = func(ctx context.Context) error { } } - gts, err := gotosocial.NewServer(dbService, router, federator) + gts, err := gotosocial.NewServer(dbService, router, federator, mediaManager) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) } diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go index 33f5217e4..d79ba3ea4 100644 --- a/cmd/gotosocial/action/testrig/testrig.go +++ b/cmd/gotosocial/action/testrig/testrig.go @@ -80,11 +80,12 @@ var Start action.GTSAction = func(ctx context.Context) error { Body: r, }, nil }), dbService) - federator := testrig.NewTestFederator(dbService, transportController, storageBackend) + mediaManager := testrig.NewTestMediaManager(dbService, storageBackend) + federator := testrig.NewTestFederator(dbService, transportController, storageBackend, mediaManager) emailSender := testrig.NewEmailSender("./web/template/", nil) - processor := testrig.NewTestProcessor(dbService, storageBackend, federator, emailSender) + processor := testrig.NewTestProcessor(dbService, storageBackend, federator, emailSender, mediaManager) if err := processor.Start(ctx); err != nil { return fmt.Errorf("error starting processor: %s", err) } @@ -156,7 +157,7 @@ var Start action.GTSAction = func(ctx context.Context) error { } } - gts, err := gotosocial.NewServer(dbService, router, federator) + gts, err := gotosocial.NewServer(dbService, router, federator, mediaManager) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) } diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go index b642dbcb4..01c8c9599 100644 --- a/internal/api/client/account/account_test.go +++ b/internal/api/client/account/account_test.go @@ -16,6 +16,7 @@ import ( "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/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -25,13 +26,14 @@ import ( type AccountStandardTestSuite struct { // standard suite interfaces suite.Suite - db db.DB - tc typeutils.TypeConverter - storage *kv.KVStore - federator federation.Federator - processor processing.Processor - emailSender email.Sender - sentEmails map[string]string + db db.DB + tc typeutils.TypeConverter + storage *kv.KVStore + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + sentEmails map[string]string // standard suite models testTokens map[string]*gtsmodel.Token @@ -61,10 +63,11 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.accountModule = account.New(suite.processor).(*account.Module) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go index 109ed4eba..5cd4b529b 100644 --- a/internal/api/client/fileserver/servefile_test.go +++ b/internal/api/client/fileserver/servefile_test.go @@ -77,10 +77,10 @@ func (suite *ServeFileTestSuite) SetupSuite() { testrig.InitTestLog() suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, testrig.NewTestMediaManager(suite.db, suite.storage)) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, testrig.NewTestMediaManager(suite.db, suite.storage)) suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) diff --git a/internal/api/client/followrequest/followrequest_test.go b/internal/api/client/followrequest/followrequest_test.go index 2d327f461..36b4912e9 100644 --- a/internal/api/client/followrequest/followrequest_test.go +++ b/internal/api/client/followrequest/followrequest_test.go @@ -33,6 +33,7 @@ import ( "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/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/testrig" @@ -40,11 +41,12 @@ import ( type FollowRequestStandardTestSuite struct { suite.Suite - db db.DB - storage *kv.KVStore - federator federation.Federator - processor processing.Processor - emailSender email.Sender + db db.DB + storage *kv.KVStore + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender // standard suite models testTokens map[string]*gtsmodel.Token @@ -74,9 +76,10 @@ func (suite *FollowRequestStandardTestSuite) SetupTest() { testrig.InitTestLog() suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.followRequestModule = followrequest.New(suite.processor).(*followrequest.Module) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 72a377c25..22e0e2188 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -51,9 +51,9 @@ type MediaCreateTestSuite struct { suite.Suite db db.DB storage *kv.KVStore + mediaManager media.Manager federator federation.Federator tc typeutils.TypeConverter - mediaManager media.Manager oauthServer oauth.Server emailSender email.Sender processor processing.Processor @@ -83,9 +83,9 @@ func (suite *MediaCreateTestSuite) SetupSuite() { suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) // setup module being tested suite.mediaModule = mediamodule.New(suite.processor).(*mediamodule.Module) diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go index dd037f6f4..c6e5b354f 100644 --- a/internal/api/client/status/status_test.go +++ b/internal/api/client/status/status_test.go @@ -26,6 +26,7 @@ import ( "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" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" @@ -34,12 +35,13 @@ import ( type StatusStandardTestSuite struct { // standard suite interfaces suite.Suite - db db.DB - tc typeutils.TypeConverter - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *kv.KVStore + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *kv.KVStore // standard suite models testTokens map[string]*gtsmodel.Token @@ -70,9 +72,10 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.tc = testrig.NewTestTypeConverter(suite.db) suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.statusModule = status.New(suite.processor).(*status.Module) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go index d60bf2f1a..e84a78cde 100644 --- a/internal/api/client/user/user_test.go +++ b/internal/api/client/user/user_test.go @@ -26,6 +26,7 @@ import ( "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" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" @@ -33,12 +34,13 @@ import ( type UserStandardTestSuite struct { suite.Suite - db db.DB - tc typeutils.TypeConverter - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *kv.KVStore + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *kv.KVStore testTokens map[string]*gtsmodel.Token testClients map[string]*gtsmodel.Client @@ -62,10 +64,11 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.userModule = user.New(suite.processor).(*user.Module) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go index 7cd9ce93a..2f43799c8 100644 --- a/internal/api/s2s/user/inboxpost_test.go +++ b/internal/api/s2s/user/inboxpost_test.go @@ -84,9 +84,9 @@ func (suite *InboxPostTestSuite) TestPostBlock() { body := bytes.NewReader(bodyJson) tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -184,9 +184,9 @@ func (suite *InboxPostTestSuite) TestPostUnblock() { body := bytes.NewReader(bodyJson) tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -274,9 +274,9 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { body := bytes.NewReader(bodyJson) tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -393,9 +393,9 @@ func (suite *InboxPostTestSuite) TestPostDelete() { body := bytes.NewReader(bodyJson) tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) err = processor.Start(context.Background()) suite.NoError(err) userModule := user.New(processor).(*user.Module) diff --git a/internal/api/s2s/user/outboxget_test.go b/internal/api/s2s/user/outboxget_test.go index 2591a80f1..3f5b54c07 100644 --- a/internal/api/s2s/user/outboxget_test.go +++ b/internal/api/s2s/user/outboxget_test.go @@ -45,9 +45,9 @@ func (suite *OutboxGetTestSuite) TestGetOutbox() { targetAccount := suite.testAccounts["local_account_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -100,9 +100,9 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { targetAccount := suite.testAccounts["local_account_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -155,9 +155,9 @@ func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() { targetAccount := suite.testAccounts["local_account_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request diff --git a/internal/api/s2s/user/repliesget_test.go b/internal/api/s2s/user/repliesget_test.go index d128e30da..81249091c 100644 --- a/internal/api/s2s/user/repliesget_test.go +++ b/internal/api/s2s/user/repliesget_test.go @@ -48,9 +48,9 @@ func (suite *RepliesGetTestSuite) TestGetReplies() { targetStatus := suite.testStatuses["local_account_1_status_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -109,9 +109,9 @@ func (suite *RepliesGetTestSuite) TestGetRepliesNext() { targetStatus := suite.testStatuses["local_account_1_status_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request @@ -173,9 +173,9 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() { targetStatus := suite.testStatuses["local_account_1_status_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go index 43b835165..b1be59073 100644 --- a/internal/api/s2s/user/user_test.go +++ b/internal/api/s2s/user/user_test.go @@ -27,6 +27,7 @@ import ( "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/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -38,6 +39,7 @@ type UserStandardTestSuite struct { suite.Suite db db.DB tc typeutils.TypeConverter + mediaManager media.Manager federator federation.Federator emailSender email.Sender processor processing.Processor @@ -77,9 +79,10 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.tc = testrig.NewTestTypeConverter(suite.db) suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.userModule = user.New(suite.processor).(*user.Module) suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module) diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go index 2f3109c92..a764f6993 100644 --- a/internal/api/s2s/user/userget_test.go +++ b/internal/api/s2s/user/userget_test.go @@ -46,9 +46,9 @@ func (suite *UserGetTestSuite) TestGetUser() { targetAccount := suite.testAccounts["local_account_1"] tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager) userModule := user.New(processor).(*user.Module) // setup request diff --git a/internal/api/s2s/webfinger/webfinger_test.go b/internal/api/s2s/webfinger/webfinger_test.go index 7e57f7e45..d7b1647b0 100644 --- a/internal/api/s2s/webfinger/webfinger_test.go +++ b/internal/api/s2s/webfinger/webfinger_test.go @@ -32,6 +32,7 @@ import ( "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/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -43,6 +44,7 @@ type WebfingerStandardTestSuite struct { suite.Suite db db.DB tc typeutils.TypeConverter + mediaManager media.Manager federator federation.Federator emailSender email.Sender processor processing.Processor @@ -80,9 +82,10 @@ func (suite *WebfingerStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.tc = testrig.NewTestTypeConverter(suite.db) suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module) diff --git a/internal/db/bundb/trace.go b/internal/db/bundb/trace.go index 3726506a9..9eaad6880 100644 --- a/internal/db/bundb/trace.go +++ b/internal/db/bundb/trace.go @@ -20,7 +20,6 @@ package bundb import ( "context" - "database/sql" "time" "github.com/sirupsen/logrus" @@ -48,13 +47,5 @@ func (q *debugQueryHook) AfterQuery(_ context.Context, event *bun.QueryEvent) { "operation": event.Operation(), }) - if event.Err != nil && event.Err != sql.ErrNoRows { - // if there's an error the it'll be handled in the application logic, - // but we can still debug log it here alongside the query - l = l.WithField("query", event.Query) - l.Debug(event.Err) - return - } - l.Tracef("[%s] %s", dur, event.Operation()) } diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index d83fc3bac..27d9f39ce 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -119,7 +119,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, username string, remoteAcc } else { // take the id we already have and do an update gtsAccount.ID = maybeAccount.ID - +aaaaaaaaaaaaaaaaaa if err := d.PopulateAccountFields(ctx, gtsAccount, username, refresh); err != nil { return nil, new, fmt.Errorf("FullyDereferenceAccount: error populating further account fields: %s", err) } @@ -260,7 +260,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * avatar := true processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ RemoteURL: &targetAccount.AvatarRemoteURL, - Avatar: &avatar, + Avatar: &avatar, }) if err != nil { return err @@ -283,7 +283,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * header := true processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ RemoteURL: &targetAccount.HeaderRemoteURL, - Header: &header, + Header: &header, }) if err != nil { return err diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 787a39739..1e6f781b8 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -41,7 +41,7 @@ type Dereferencer interface { GetRemoteInstance(ctx context.Context, username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) - GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Media, error) + GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Processing, error) DereferenceAnnounce(ctx context.Context, announce *gtsmodel.Status, requestingUsername string) error DereferenceThread(ctx context.Context, username string, statusIRI *url.URL) error diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index 0ddab7ae0..f02303aa1 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -26,7 +26,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/media" ) -func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Media, error) { +func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Processing, error) { if accountID == "" { return nil, fmt.Errorf("RefreshAttachment: minAttachment account ID was empty") } @@ -46,10 +46,10 @@ func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, a return nil, fmt.Errorf("RefreshAttachment: error dereferencing media: %s", err) } - m, err := d.mediaManager.ProcessMedia(ctx, data, accountID, ai) + processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, accountID, ai) if err != nil { return nil, fmt.Errorf("RefreshAttachment: error processing attachment: %s", err) } - return m, nil + return processingMedia, nil } diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go index 1f076f62c..61ee6edb6 100644 --- a/internal/federation/dereferencing/media_test.go +++ b/internal/federation/dereferencing/media_test.go @@ -52,8 +52,8 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() { }) suite.NoError(err) - // make a blocking call to load the attachment from the in-process media - attachment, err := media.LoadAttachment(ctx) + // make a blocking call to load the attachment from the in-process media + attachment, err := media.Load(ctx) suite.NoError(err) suite.NotNil(attachment) diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 47ce087a2..041cfa6b4 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -406,7 +406,7 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. continue } - attachment, err := media.LoadAttachment(ctx) + attachment, err := media.Load(ctx) if err != nil { logrus.Errorf("populateStatusAttachments: couldn't load remote attachment %s: %s", a.RemoteURL, err) continue diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go index 01a77ce2a..7b2d16e5e 100644 --- a/internal/gotosocial/gotosocial.go +++ b/internal/gotosocial/gotosocial.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -41,19 +42,21 @@ type Server interface { // NewServer returns a new gotosocial server, initialized with the given configuration. // An error will be returned the caller if something goes wrong during initialization // eg., no db or storage connection, port for router already in use, etc. -func NewServer(db db.DB, apiRouter router.Router, federator federation.Federator) (Server, error) { +func NewServer(db db.DB, apiRouter router.Router, federator federation.Federator, mediaManager media.Manager) (Server, error) { return &gotosocial{ - db: db, - apiRouter: apiRouter, - federator: federator, + db: db, + apiRouter: apiRouter, + federator: federator, + mediaManager: mediaManager, }, nil } // gotosocial fulfils the gotosocial interface. type gotosocial struct { - db db.DB - apiRouter router.Router - federator federation.Federator + db db.DB + apiRouter router.Router + federator federation.Federator + mediaManager media.Manager } // Start starts up the gotosocial server. If something goes wrong @@ -63,13 +66,16 @@ func (gts *gotosocial) Start(ctx context.Context) error { return nil } -// Stop closes down the gotosocial server, first closing the router -// then the database. If something goes wrong while stopping, an -// error will be returned. +// Stop closes down the gotosocial server, first closing the router, +// then the media manager, then the database. +// If something goes wrong while stopping, an error will be returned. func (gts *gotosocial) Stop(ctx context.Context) error { if err := gts.apiRouter.Stop(ctx); err != nil { return err } + if err := gts.mediaManager.Stop(); err != nil { + return err + } if err := gts.db.Stop(ctx); err != nil { return err } diff --git a/internal/media/image.go b/internal/media/image.go index 4c0b28c02..074dd3839 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -20,16 +20,22 @@ package media import ( "bytes" + "context" "errors" "fmt" "image" "image/gif" "image/jpeg" "image/png" + "strings" + "time" "github.com/buckket/go-blurhash" "github.com/nfnt/resize" "github.com/superseriousbusiness/exifremove/pkg/exifremove" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" ) const ( @@ -38,13 +44,119 @@ const ( ) type ImageMeta struct { - image []byte - contentType string - width int - height int - size int - aspect float64 - blurhash string + image []byte + width int + height int + size int + aspect float64 + blurhash string +} + +func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, ai *AdditionalInfo) (*Processing, error) { + if !supportedImage(contentType) { + return nil, fmt.Errorf("image type %s not supported", contentType) + } + + if len(data) == 0 { + return nil, errors.New("image was of size 0") + } + + id, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + extension := strings.Split(contentType, "/")[1] + + // populate initial fields on the media attachment -- some of these will be overwritten as we proceed + attachment := >smodel.MediaAttachment{ + ID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StatusID: "", + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), + RemoteURL: "", + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: "", + Processing: gtsmodel.ProcessingStatusReceived, + File: gtsmodel.File{ + Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), + ContentType: contentType, + UpdatedAt: time.Now(), + }, + Thumbnail: gtsmodel.Thumbnail{ + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, + Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, + ContentType: mimeJpeg, + UpdatedAt: time.Now(), + }, + Avatar: false, + Header: false, + } + + // check if we have additional info to add to the attachment, + // and overwrite some of the attachment fields if so + if ai != nil { + if ai.CreatedAt != nil { + attachment.CreatedAt = *ai.CreatedAt + } + + if ai.StatusID != nil { + attachment.StatusID = *ai.StatusID + } + + if ai.RemoteURL != nil { + attachment.RemoteURL = *ai.RemoteURL + } + + if ai.Description != nil { + attachment.Description = *ai.Description + } + + if ai.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *ai.ScheduledStatusID + } + + if ai.Blurhash != nil { + attachment.Blurhash = *ai.Blurhash + } + + if ai.Avatar != nil { + attachment.Avatar = *ai.Avatar + } + + if ai.Header != nil { + attachment.Header = *ai.Header + } + + if ai.FocusX != nil { + attachment.FileMeta.Focus.X = *ai.FocusX + } + + if ai.FocusY != nil { + attachment.FileMeta.Focus.Y = *ai.FocusY + } + } + + media := &Processing{ + attachment: attachment, + rawData: data, + thumbstate: received, + fullSizeState: received, + database: m.db, + storage: m.storage, + } + + return media, nil } func decodeGif(b []byte) (*ImageMeta, error) { @@ -106,8 +218,12 @@ func decodeImage(b []byte, contentType string) (*ImageMeta, error) { // deriveThumbnail returns a byte slice and metadata for a thumbnail // of a given jpeg, png, or gif, or an error if something goes wrong. // -// Note that the aspect ratio of the image will be retained, -// so it will not necessarily be a square, even if x and y are set as the same value. +// If createBlurhash is true, then a blurhash will also be generated from a tiny +// version of the image. This costs precious CPU cycles, so only use it if you +// really need a blurhash and don't have one already. +// +// If createBlurhash is false, then the blurhash field on the returned ImageAndMeta +// will be an empty string. func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageMeta, error) { var i image.Image var err error @@ -115,21 +231,20 @@ func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageM switch contentType { case mimeImageJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } case mimeImagePng: i, err = png.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } case mimeImageGif: i, err = gif.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } default: - return nil, fmt.Errorf("content type %s not recognised", contentType) + err = fmt.Errorf("content type %s can't be thumbnailed", contentType) + } + + if err != nil { + return nil, err + } + + if i == nil { + return nil, errors.New("processed image was nil") } thumb := resize.Thumbnail(thumbnailMaxWidth, thumbnailMaxHeight, i, resize.NearestNeighbor) @@ -146,6 +261,8 @@ func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageM } if createBlurhash { + // for generating blurhashes, it's more cost effective to lose detail rather than + // pass a big image into the blurhash algorithm, so make a teeny tiny version tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor) bh, err := blurhash.Encode(4, 3, tiny) if err != nil { @@ -156,6 +273,7 @@ func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageM out := &bytes.Buffer{} if err := jpeg.Encode(out, thumb, &jpeg.Options{ + // Quality isn't extremely important for thumbnails, so 75 is "good enough" Quality: 75, }); err != nil { return nil, err diff --git a/internal/media/manager.go b/internal/media/manager.go index 5e62b39b2..c8642fcb4 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -24,63 +24,84 @@ import ( "fmt" "runtime" "strings" - "time" "codeberg.org/gruf/go-runners" "codeberg.org/gruf/go-store/kv" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/uris" ) // Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs. type Manager interface { // ProcessMedia begins the process of decoding and storing the given data as a piece of media (aka an attachment). // It will return a pointer to a Media struct upon which further actions can be performed, such as getting - // the finished media, thumbnail, decoded bytes, attachment, and setting additional fields. + // the finished media, thumbnail, attachment, etc. // // accountID should be the account that the media belongs to. // - // RemoteURL is optional, and can be an empty string. Setting this to a non-empty string indicates that - // the piece of media originated on a remote instance and has been dereferenced to be cached locally. - ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Media, error) - - ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Media, error) + // ai is optional and can be nil. Any additional information about the attachment provided will be put in the database. + ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Processing, error) + ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Processing, error) + // NumWorkers returns the total number of workers available to this manager. + NumWorkers() int + // QueueSize returns the total capacity of the queue. + QueueSize() int + // JobsQueued returns the number of jobs currently in the task queue. + JobsQueued() int + // ActiveWorkers returns the number of workers currently performing jobs. + ActiveWorkers() int + // Stop stops the underlying worker pool of the manager. It should be called + // when closing GoToSocial in order to cleanly finish any in-progress jobs. + // It will block until workers are finished processing. + Stop() error } type manager struct { - db db.DB - storage *kv.KVStore - pool runners.WorkerPool + db db.DB + storage *kv.KVStore + pool runners.WorkerPool + numWorkers int + queueSize int } -// New returns a media manager with the given db and underlying storage. -func New(database db.DB, storage *kv.KVStore) (Manager, error) { - workers := runtime.NumCPU() / 2 - queue := workers * 10 - pool := runners.NewWorkerPool(workers, queue) - - if start := pool.Start(); !start { - return nil, errors.New("could not start worker pool") +// NewManager returns a media manager with the given db and underlying storage. +// +// A worker pool will also be initialized for the manager, to ensure that only +// a limited number of media will be processed in parallel. +// +// The number of workers will be the number of CPUs available to the Go runtime, +// divided by 2 (rounding down, but always at least 1). +// +// The length of the queue will be the number of workers multiplied by 10. +// +// So for an 8 core machine, the media manager will get 4 workers, and a queue of length 40. +// For a 4 core machine, this will be 2 workers, and a queue length of 20. +// For a single or 2-core machine, the media manager will get 1 worker, and a queue of length 10. +func NewManager(database db.DB, storage *kv.KVStore) (Manager, error) { + numWorkers := runtime.NumCPU() / 2 + // make sure we always have at least 1 worker even on single-core machines + if numWorkers == 0 { + numWorkers = 1 } - logrus.Debugf("started media manager worker pool with %d workers and queue capacity of %d", workers, queue) + queueSize := numWorkers * 10 m := &manager{ - db: database, - storage: storage, - pool: pool, + db: database, + storage: storage, + pool: runners.NewWorkerPool(numWorkers, queueSize), + numWorkers: numWorkers, + queueSize: queueSize, + } + + if start := m.pool.Start(); !start { + return nil, errors.New("could not start worker pool") } + logrus.Debugf("started media manager worker pool with %d workers and queue capacity of %d", numWorkers, queueSize) return m, nil } -/* - INTERFACE FUNCTIONS -*/ - -func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Media, error) { +func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Processing, error) { contentType, err := parseContentType(data) if err != nil { return nil, err @@ -100,16 +121,20 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin return nil, err } + logrus.Tracef("ProcessMedia: about to enqueue media with attachmentID %s, queue length is %d", media.AttachmentID(), m.pool.Queue()) m.pool.Enqueue(func(innerCtx context.Context) { select { case <-innerCtx.Done(): // if the inner context is done that means the worker pool is closing, so we should just return return default: - // start preloading the media for the caller's convenience - media.preLoad(innerCtx) + // start loading the media already for the caller's convenience + if _, err := media.Load(innerCtx); err != nil { + logrus.Errorf("ProcessMedia: error processing media with attachmentID %s: %s", media.AttachmentID(), err) + } } }) + logrus.Tracef("ProcessMedia: succesfully queued media with attachmentID %s, queue length is %d", media.AttachmentID(), m.pool.Queue()) return media, nil default: @@ -117,112 +142,32 @@ func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID strin } } -func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Media, error) { +func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Processing, error) { return nil, nil } -// preProcessImage initializes processing -func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, ai *AdditionalInfo) (*Media, error) { - if !supportedImage(contentType) { - return nil, fmt.Errorf("image type %s not supported", contentType) - } - - if len(data) == 0 { - return nil, errors.New("image was of size 0") - } - - id, err := id.NewRandomULID() - if err != nil { - return nil, err - } - - extension := strings.Split(contentType, "/")[1] - - attachment := >smodel.MediaAttachment{ - ID: id, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - StatusID: "", - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), - RemoteURL: "", - Type: gtsmodel.FileTypeImage, - FileMeta: gtsmodel.FileMeta{ - Focus: gtsmodel.Focus{ - X: 0, - Y: 0, - }, - }, - AccountID: accountID, - Description: "", - ScheduledStatusID: "", - Blurhash: "", - Processing: 0, - File: gtsmodel.File{ - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), - ContentType: contentType, - UpdatedAt: time.Now(), - }, - Thumbnail: gtsmodel.Thumbnail{ - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, - ContentType: mimeJpeg, - UpdatedAt: time.Now(), - }, - Avatar: false, - Header: false, - } - - // check if we have additional info to add to the attachment - if ai != nil { - if ai.CreatedAt != nil { - attachment.CreatedAt = *ai.CreatedAt - } - - if ai.StatusID != nil { - attachment.StatusID = *ai.StatusID - } - - if ai.RemoteURL != nil { - attachment.RemoteURL = *ai.RemoteURL - } - - if ai.Description != nil { - attachment.Description = *ai.Description - } - - if ai.ScheduledStatusID != nil { - attachment.ScheduledStatusID = *ai.ScheduledStatusID - } - - if ai.Blurhash != nil { - attachment.Blurhash = *ai.Blurhash - } +func (m *manager) NumWorkers() int { + return m.numWorkers +} - if ai.Avatar != nil { - attachment.Avatar = *ai.Avatar - } +func (m *manager) QueueSize() int { + return m.queueSize +} - if ai.Header != nil { - attachment.Header = *ai.Header - } +func (m *manager) JobsQueued() int { + return m.pool.Queue() +} - if ai.FocusX != nil { - attachment.FileMeta.Focus.X = *ai.FocusX - } +func (m *manager) ActiveWorkers() int { + return m.pool.Workers() +} - if ai.FocusY != nil { - attachment.FileMeta.Focus.Y = *ai.FocusY - } - } +func (m *manager) Stop() error { + logrus.Info("stopping media manager worker pool") - media := &Media{ - attachment: attachment, - rawData: data, - thumbstate: received, - fullSizeState: received, - database: m.db, - storage: m.storage, + stopped := m.pool.Stop() + if !stopped { + return errors.New("could not stop media manager worker pool") } - - return media, nil + return nil } diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index aad7f46d0..74d0c3008 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -19,36 +19,238 @@ package media_test import ( - "codeberg.org/gruf/go-store/kv" + "context" + "fmt" + "os" + "testing" + "time" + "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20211113114307_init" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/testrig" ) -type MediaManagerStandardTestSuite struct { - suite.Suite +type ManagerTestSuite struct { + MediaStandardTestSuite +} + +func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { + ctx := context.Background() + + // load bytes from a test image + testBytes, err := os.ReadFile("./test/test-jpeg.jpg") + suite.NoError(err) + suite.NotEmpty(testBytes) + + accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + + // process the media with no additional info provided + processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + suite.NoError(err) + // fetch the attachment id from the processing media + attachmentID := processingMedia.AttachmentID() + + // do a blocking call to fetch the attachment + attachment, err := processingMedia.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) + + // make sure it's got the stuff set on it that we expect + // the attachment ID and accountID we expect + suite.Equal(attachmentID, attachment.ID) + suite.Equal(accountID, attachment.AccountID) + + // file meta should be correctly derived from the image + suite.EqualValues(gtsmodel.Original{ + Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Original) + suite.EqualValues(gtsmodel.Small{ + Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Small) + suite.Equal("image/jpeg", attachment.File.ContentType) + suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash) + + // now make sure the attachment is in the database + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + suite.NoError(err) + suite.NotNil(dbAttachment) + + // make sure the processed file is in storage + processedFullBytes, err := suite.storage.Get(attachment.File.Path) + suite.NoError(err) + suite.NotEmpty(processedFullBytes) - db db.DB - storage *kv.KVStore - manager media.Manager + // load the processed bytes from our test folder, to compare + processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg") + suite.NoError(err) + suite.NotEmpty(processedFullBytesExpected) + + // the bytes in storage should be what we expected + suite.Equal(processedFullBytesExpected, processedFullBytes) + + // now do the same for the thumbnail and make sure it's what we expected + processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path) + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytes) + + processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg") + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytesExpected) + + suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *MediaManagerStandardTestSuite) SetupSuite() { - testrig.InitTestLog() - testrig.InitTestConfig() +func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { + ctx := context.Background() + + // load bytes from a test image + testBytes, err := os.ReadFile("./test/test-jpeg.jpg") + suite.NoError(err) + suite.NotEmpty(testBytes) + + accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + + // process the media with no additional info provided + processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + suite.NoError(err) + // fetch the attachment id from the processing media + attachmentID := processingMedia.AttachmentID() + + // wait for the media to finish processing + for finished := processingMedia.Finished(); !finished; finished = processingMedia.Finished() { + time.Sleep(10 * time.Millisecond) + fmt.Printf("\n\nnot finished yet...\n\n") + } + fmt.Printf("\n\nfinished!\n\n") + + // fetch the attachment from the database + attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + suite.NoError(err) + suite.NotNil(attachment) + + // make sure it's got the stuff set on it that we expect + // the attachment ID and accountID we expect + suite.Equal(attachmentID, attachment.ID) + suite.Equal(accountID, attachment.AccountID) + + // file meta should be correctly derived from the image + suite.EqualValues(gtsmodel.Original{ + Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Original) + suite.EqualValues(gtsmodel.Small{ + Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Small) + suite.Equal("image/jpeg", attachment.File.ContentType) + suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash) - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() + // now make sure the attachment is in the database + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + suite.NoError(err) + suite.NotNil(dbAttachment) + + // make sure the processed file is in storage + processedFullBytes, err := suite.storage.Get(attachment.File.Path) + suite.NoError(err) + suite.NotEmpty(processedFullBytes) + + // load the processed bytes from our test folder, to compare + processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg") + suite.NoError(err) + suite.NotEmpty(processedFullBytesExpected) + + // the bytes in storage should be what we expected + suite.Equal(processedFullBytesExpected, processedFullBytes) + + // now do the same for the thumbnail and make sure it's what we expected + processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path) + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytes) + + processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg") + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytesExpected) + + suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *MediaManagerStandardTestSuite) SetupTest() { - testrig.StandardStorageSetup(suite.storage, "../../testrig/media") - testrig.StandardDBSetup(suite.db, nil) - suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage) +func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() { + // in this test, we spam the manager queue with 50 new media requests, just to see how it holds up + + ctx := context.Background() + + // load bytes from a test image + testBytes, err := os.ReadFile("./test/test-jpeg.jpg") + suite.NoError(err) + suite.NotEmpty(testBytes) + + accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + + spam := 50 + inProcess := []*media.Processing{} + for i := 0; i < spam; i++ { + // process the media with no additional info provided + processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + suite.NoError(err) + inProcess = append(inProcess, processingMedia) + } + + for _, processingMedia := range inProcess { + fmt.Printf("\n\n\nactive workers: %d, queue length: %d\n\n\n", suite.manager.ActiveWorkers(), suite.manager.JobsQueued()) + + // fetch the attachment id from the processing media + attachmentID := processingMedia.AttachmentID() + + // do a blocking call to fetch the attachment + attachment, err := processingMedia.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) + + // make sure it's got the stuff set on it that we expect + // the attachment ID and accountID we expect + suite.Equal(attachmentID, attachment.ID) + suite.Equal(accountID, attachment.AccountID) + + // file meta should be correctly derived from the image + suite.EqualValues(gtsmodel.Original{ + Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Original) + suite.EqualValues(gtsmodel.Small{ + Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, + }, attachment.FileMeta.Small) + suite.Equal("image/jpeg", attachment.File.ContentType) + suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash) + + // now make sure the attachment is in the database + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + suite.NoError(err) + suite.NotNil(dbAttachment) + + // make sure the processed file is in storage + processedFullBytes, err := suite.storage.Get(attachment.File.Path) + suite.NoError(err) + suite.NotEmpty(processedFullBytes) + + // load the processed bytes from our test folder, to compare + processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg") + suite.NoError(err) + suite.NotEmpty(processedFullBytesExpected) + + // the bytes in storage should be what we expected + suite.Equal(processedFullBytesExpected, processedFullBytes) + + // now do the same for the thumbnail and make sure it's what we expected + processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path) + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytes) + + processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg") + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytesExpected) + + suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) + } } -func (suite *MediaManagerStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) +func TestManagerTestSuite(t *testing.T) { + suite.Run(t, &ManagerTestSuite{}) } diff --git a/internal/media/media.go b/internal/media/media.go deleted file mode 100644 index e0cfe09b7..000000000 --- a/internal/media/media.go +++ /dev/null @@ -1,258 +0,0 @@ -/* - 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 media - -import ( - "context" - "fmt" - "sync" - "time" - - "codeberg.org/gruf/go-store/kv" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type processState int - -const ( - received processState = iota // processing order has been received but not done yet - complete // processing order has been completed successfully - errored // processing order has been completed with an error -) - -type Media struct { - mu sync.Mutex - - /* - below fields should be set on newly created media; - attachment will be updated incrementally as media goes through processing - */ - - attachment *gtsmodel.MediaAttachment // will only be set if the media is an attachment - emoji *gtsmodel.Emoji // will only be set if the media is an emoji - - rawData []byte - - /* - below fields represent the processing state of the media thumbnail - */ - - thumbstate processState - thumb *ImageMeta - - /* - below fields represent the processing state of the full-sized media - */ - - fullSizeState processState - fullSize *ImageMeta - - /* - below pointers to database and storage are maintained so that - the media can store and update itself during processing steps - */ - - database db.DB - storage *kv.KVStore - - err error // error created during processing, if any -} - -func (m *Media) Thumb(ctx context.Context) (*ImageMeta, error) { - m.mu.Lock() - defer m.mu.Unlock() - - switch m.thumbstate { - case received: - // we haven't processed a thumbnail for this media yet so do it now - - // check if we need to create a blurhash or if there's already one set - var createBlurhash bool - if m.attachment.Blurhash == "" { - // no blurhash created yet - createBlurhash = true - } - - thumb, err := deriveThumbnail(m.rawData, m.attachment.File.ContentType, createBlurhash) - if err != nil { - m.err = fmt.Errorf("error deriving thumbnail: %s", err) - m.thumbstate = errored - return nil, m.err - } - - // put the thumbnail in storage - if err := m.storage.Put(m.attachment.Thumbnail.Path, thumb.image); err != nil { - m.err = fmt.Errorf("error storing thumbnail: %s", err) - m.thumbstate = errored - return nil, m.err - } - - // set appropriate fields on the attachment based on the thumbnail we derived - if createBlurhash { - m.attachment.Blurhash = thumb.blurhash - } - - m.attachment.FileMeta.Small = gtsmodel.Small{ - Width: thumb.width, - Height: thumb.height, - Size: thumb.size, - Aspect: thumb.aspect, - } - m.attachment.Thumbnail.FileSize = thumb.size - - if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { - m.err = err - m.thumbstate = errored - return nil, err - } - - // set the thumbnail of this media - m.thumb = thumb - - // we're done processing the thumbnail! - m.thumbstate = complete - fallthrough - case complete: - return m.thumb, nil - case errored: - return nil, m.err - } - - return nil, fmt.Errorf("thumbnail processing status %d unknown", m.thumbstate) -} - -func (m *Media) FullSize(ctx context.Context) (*ImageMeta, error) { - m.mu.Lock() - defer m.mu.Unlock() - - switch m.fullSizeState { - case received: - var clean []byte - var err error - var decoded *ImageMeta - - ct := m.attachment.File.ContentType - switch ct { - case mimeImageJpeg, mimeImagePng: - // first 'clean' image by purging exif data from it - var exifErr error - if clean, exifErr = purgeExif(m.rawData); exifErr != nil { - err = exifErr - break - } - decoded, err = decodeImage(clean, ct) - case mimeImageGif: - // gifs are already clean - no exif data to remove - clean = m.rawData - decoded, err = decodeGif(clean) - default: - err = fmt.Errorf("content type %s not a processible image type", ct) - } - - if err != nil { - m.err = err - m.fullSizeState = errored - return nil, err - } - - // put the full size in storage - if err := m.storage.Put(m.attachment.File.Path, decoded.image); err != nil { - m.err = fmt.Errorf("error storing full size image: %s", err) - m.fullSizeState = errored - return nil, m.err - } - - // set appropriate fields on the attachment based on the image we derived - m.attachment.FileMeta.Original = gtsmodel.Original{ - Width: decoded.width, - Height: decoded.height, - Size: decoded.size, - Aspect: decoded.aspect, - } - m.attachment.File.FileSize = decoded.size - m.attachment.File.UpdatedAt = time.Now() - m.attachment.Processing = gtsmodel.ProcessingStatusProcessed - - if err := putOrUpdateAttachment(ctx, m.database, m.attachment); err != nil { - m.err = err - m.fullSizeState = errored - return nil, err - } - - // set the fullsize of this media - m.fullSize = decoded - - // we're done processing the full-size image - m.fullSizeState = complete - fallthrough - case complete: - return m.fullSize, nil - case errored: - return nil, m.err - } - - return nil, fmt.Errorf("full size processing status %d unknown", m.fullSizeState) -} - -// AttachmentID returns the ID of the underlying media attachment without blocking processing. -func (m *Media) AttachmentID() string { - return m.attachment.ID -} - -// preLoad begins the process of deriving the thumbnail and encoding the full-size image. -// It does this in a non-blocking way, so you can call it and then come back later and check -// if it's finished. -func (m *Media) preLoad(ctx context.Context) { - go m.Thumb(ctx) - go m.FullSize(ctx) -} - -// Load is the blocking equivalent of pre-load. It makes sure the thumbnail and full-size -// image have been processed, then it returns the completed attachment. -func (m *Media) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - if _, err := m.Thumb(ctx); err != nil { - return nil, err - } - - if _, err := m.FullSize(ctx); err != nil { - return nil, err - } - - return m.attachment, nil -} - -func (m *Media) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - return nil, nil -} - -// putOrUpdateAttachment is just a convenience function for first trying to PUT the attachment in the database, -// and then if that doesn't work because the attachment already exists, updating it instead. -func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { - if err := database.Put(ctx, attachment); err != nil { - if err != db.ErrAlreadyExists { - return fmt.Errorf("putOrUpdateAttachment: proper error while putting attachment: %s", err) - } - if err := database.UpdateByPrimaryKey(ctx, attachment); err != nil { - return fmt.Errorf("putOrUpdateAttachment: error while updating attachment: %s", err) - } - } - - return nil -} diff --git a/internal/media/media_test.go b/internal/media/media_test.go new file mode 100644 index 000000000..f3e73ed79 --- /dev/null +++ b/internal/media/media_test.go @@ -0,0 +1,54 @@ +/* + 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 media_test + +import ( + "codeberg.org/gruf/go-store/kv" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MediaStandardTestSuite struct { + suite.Suite + + db db.DB + storage *kv.KVStore + manager media.Manager +} + +func (suite *MediaStandardTestSuite) SetupSuite() { + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() +} + +func (suite *MediaStandardTestSuite) SetupTest() { + testrig.StandardStorageSetup(suite.storage, "../../testrig/media") + testrig.StandardDBSetup(suite.db, nil) + suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage) +} + +func (suite *MediaStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/media/processing.go b/internal/media/processing.go new file mode 100644 index 000000000..3f9fc2bfc --- /dev/null +++ b/internal/media/processing.go @@ -0,0 +1,256 @@ +/* + 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 media + +import ( + "context" + "fmt" + "sync" + "time" + + "codeberg.org/gruf/go-store/kv" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type processState int + +const ( + received processState = iota // processing order has been received but not done yet + complete // processing order has been completed successfully + errored // processing order has been completed with an error +) + +// Processing represents a piece of media that is currently being processed. It exposes +// various functions for retrieving data from the process. +type Processing struct { + mu sync.Mutex + + /* + below fields should be set on newly created media; + attachment will be updated incrementally as media goes through processing + */ + + attachment *gtsmodel.MediaAttachment // will only be set if the media is an attachment + emoji *gtsmodel.Emoji // will only be set if the media is an emoji + + rawData []byte + + /* + below fields represent the processing state of the media thumbnail + */ + + thumbstate processState + thumb *ImageMeta + + /* + below fields represent the processing state of the full-sized media + */ + + fullSizeState processState + fullSize *ImageMeta + + /* + below pointers to database and storage are maintained so that + the media can store and update itself during processing steps + */ + + database db.DB + storage *kv.KVStore + + err error // error created during processing, if any +} + +func (p *Processing) Thumb(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.thumbstate { + case received: + // we haven't processed a thumbnail for this media yet so do it now + + // check if we need to create a blurhash or if there's already one set + var createBlurhash bool + if p.attachment.Blurhash == "" { + // no blurhash created yet + createBlurhash = true + } + + thumb, err := deriveThumbnail(p.rawData, p.attachment.File.ContentType, createBlurhash) + if err != nil { + p.err = fmt.Errorf("error deriving thumbnail: %s", err) + p.thumbstate = errored + return nil, p.err + } + + // put the thumbnail in storage + if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.image); err != nil { + p.err = fmt.Errorf("error storing thumbnail: %s", err) + p.thumbstate = errored + return nil, p.err + } + + // set appropriate fields on the attachment based on the thumbnail we derived + if createBlurhash { + p.attachment.Blurhash = thumb.blurhash + } + + p.attachment.FileMeta.Small = gtsmodel.Small{ + Width: thumb.width, + Height: thumb.height, + Size: thumb.size, + Aspect: thumb.aspect, + } + p.attachment.Thumbnail.FileSize = thumb.size + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.thumbstate = errored + return nil, err + } + + // set the thumbnail of this media + p.thumb = thumb + + // we're done processing the thumbnail! + p.thumbstate = complete + fallthrough + case complete: + return p.thumb, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("thumbnail processing status %d unknown", p.thumbstate) +} + +func (p *Processing) FullSize(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.fullSizeState { + case received: + var clean []byte + var err error + var decoded *ImageMeta + + ct := p.attachment.File.ContentType + switch ct { + case mimeImageJpeg, mimeImagePng: + // first 'clean' image by purging exif data from it + var exifErr error + if clean, exifErr = purgeExif(p.rawData); exifErr != nil { + err = exifErr + break + } + decoded, err = decodeImage(clean, ct) + case mimeImageGif: + // gifs are already clean - no exif data to remove + clean = p.rawData + decoded, err = decodeGif(clean) + default: + err = fmt.Errorf("content type %s not a processible image type", ct) + } + + if err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // put the full size in storage + if err := p.storage.Put(p.attachment.File.Path, decoded.image); err != nil { + p.err = fmt.Errorf("error storing full size image: %s", err) + p.fullSizeState = errored + return nil, p.err + } + + // set appropriate fields on the attachment based on the image we derived + p.attachment.FileMeta.Original = gtsmodel.Original{ + Width: decoded.width, + Height: decoded.height, + Size: decoded.size, + Aspect: decoded.aspect, + } + p.attachment.File.FileSize = decoded.size + p.attachment.File.UpdatedAt = time.Now() + p.attachment.Processing = gtsmodel.ProcessingStatusProcessed + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // set the fullsize of this media + p.fullSize = decoded + + // we're done processing the full-size image + p.fullSizeState = complete + fallthrough + case complete: + return p.fullSize, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) +} + +// AttachmentID returns the ID of the underlying media attachment without blocking processing. +func (p *Processing) AttachmentID() string { + return p.attachment.ID +} + +// Load blocks until the thumbnail and fullsize content has been processed, and then +// returns the completed attachment. +func (p *Processing) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { + if _, err := p.Thumb(ctx); err != nil { + return nil, err + } + + if _, err := p.FullSize(ctx); err != nil { + return nil, err + } + + return p.attachment, nil +} + +func (p *Processing) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { + return nil, nil +} + +func (p *Processing) Finished() bool { + return p.thumbstate == complete && p.fullSizeState == complete +} + +// putOrUpdateAttachment is just a convenience function for first trying to PUT the attachment in the database, +// and then if that doesn't work because the attachment already exists, updating it instead. +func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { + if err := database.Put(ctx, attachment); err != nil { + if err != db.ErrAlreadyExists { + return fmt.Errorf("putOrUpdateAttachment: proper error while putting attachment: %s", err) + } + if err := database.UpdateByPrimaryKey(ctx, attachment); err != nil { + return fmt.Errorf("putOrUpdateAttachment: error while updating attachment: %s", err) + } + } + + return nil +} diff --git a/internal/media/test/test-corrupted.jpg b/internal/media/test/test-corrupted.jpg deleted file mode 100644 index 86e4d75ce..000000000 --- a/internal/media/test/test-corrupted.jpg +++ /dev/null @@ -1 +0,0 @@ -3BHI03zDX4cEuh#Ak%Ag~GOs8Q#dXdu9zH*51jjoO%FYNf*wa&!G-*uN&iLPb0%^FtLLqcQX6x4CorRP82Q8AYsLi!uL!lyX&u^w6xUiwmX*vX^S#waa_A6&CnDO7rXO%3ICVdmGSaUcaVfD2oki4LQO9*b~YM$-@*i2^BnFVPdKw1Nyt9xb$QK3*um4CHpWi^!t#GL8C-VnAOU2Sr6hThro6HA9LBR6nM_oc~bfxR789@mrsG6hH5ZM%uO1IGmHJX4fq!HyL0iYLv*wvhUWqztsyxwXPpyDnZN~PA!uG#*wSxKoVwMITI5hKGEEGk5BK4W~z80WzBr2s~D%Gzks5SNG2hIT@5lQowr0&DWCi2BwDpN0r4P0F2mmds_teB$NoTllvH4vnJ&$C#M5DBXDJdUgI4iZ$G_ZQZLxYW%TzO~2_-vwQLP1J3Y-aXsXlPNCbNmoz1BvQkx*UUP~Nn6sLIrrzarKLtyo2Zb5B584~rA24AN8NV-6!XTS2DP00&$bwRVYOB79sdYwpHaw!Gu&W@vf~TPVTLA~jmals4#~OQuOH%uaL2boZ~_@_81FI3I_Jx_ida9Fzr7z6WxNNFeOEAMBP98b_f6wtmbcFxPen#PHzeDj*xPbdnP^iLMY~zT^YWYICGaR~A0hx_oVtT#V*1-IgW@9TtQDcIe#SiU8jpKEp5nwJ@c6heA2wmPdru!P8b#H2uOokHZd$sGgx&V6zO90S8&kNUjD^D~wsYhZ0p~B2bOa!OimhA^GV440EqQ6o6HWzEGp*zyQ48jXB$LZyzbUtLx7SZw$PmvtcK@4DtZOwyG5fp0Y_6YC~zGYWbLaCVIDy#dBfGMFtCXg-TxKXVk~PHm3@a3po-*6DY2L@PKQp8YZqgB%PgnlNUYHpSryaSvsh$G4C*UW%MkiyC_TScXLYdgyLV@#oG~8U$kA4$*rrbShcL#gi#dr@DBOXO$o0n^p0-nu$mBQI2qYdP%FympbjhK6MI&~U^KQpFcYjXp^*#Op4YaKP4-O3Ibs889Lgr8*NroQ8$2nr^rK3sgOlydjUdi%j_u@YPQzJJDYgxr*Z*qivABBMLLqXv6T^ZAllo3%d3iyW9uOlsrB~2rG$zKLUAnP98VjR!MV1^vm@L~eobU7~&fJw~o1@uj1sokB4KnVk&@_YVkYWu*2fcUY6fvNx~!y09*e@nU1oay0Ai&XGSKw6n2T4cL_Pgtf6XtGgXH~wiWkVL&6LKbO38P9poIX#01w17xVbKfZAoFwxD8$1l9VV-WwI@404Z-EJoDCE2!kGMcOfdrYJhBu7#vJ*aOVO4Lgxoh7ybUefdC0DHht51deeEgWxNq8npu0%pMvu2dPM&Cnx#knz2CO9GQL#A%wB6fXBx-dit$Wf@ii%#~ttUon&eYP&iFLC*475CWN8&cMg#4i~dS*O4mJVD@xELn3LzuKJk-W7Pkj~5NZlhVL0pr_RH5Oic7GMWK&rsB~FY-%PGqpS6E4Rmhr~*^n@I7EY&FvTHn*1gJ@m139p_hT3Gh6uteFxVhlD9r471l1&#sJ6b9aut5yFHhxsVAvMeI5i1A!P0$-BGsegn8YpjNf15Ce0Dz%Wb7NYp5eY2J!dRZS8Tld!e1_72ER8KUIW%&NKGbL09Z~!F7322O^wjCaV~49jxCu6c1Dqdg0ZO^iT0#mkg@BwAytt^c%H^bI45mkegz0btunQ6zJv0Ecypsf99sGsIomRB#rbluVc1mU*DjA&Y-WV-XhzI$^RCSdH2k8jTRZRv3w7%3kNLQ47Q*p$brF-Zqtv0pkMe^a!cmZ@jDx##^6893Uuv_iH^#sA1XeMvE50wsP#gD0w2LbEh0iq_NMe%e~x^AO3kK8UbVBd2y*zQ!-y-o-^0sZ5xe#*BDJk$aD8#8Or*&0Jr8A$-Z^0Mx1awGh55e%69Di^JAjr3FE9B_ZpoH*uha%SxjPJWRU%ElstN!7L4z#@1ReWdY8QvdfFVUe_zNnP_pKcI8iW&92Eh@IVVH~6EJT$tlUeiz1QLKFE&f5PLKwXNl#trj&@cmKfJ87mMNRb6e@zWeOXr9&%3KSpo9&_6kAoOgHh@f-%D@GOVTf7AWqIc8m9BNd0EE_@ApS44Y-g$*#s_bO3%B^G_er05bqNxoQ$H@GAlUnhXcgo@sY2SxR1xm@51CL^*sYupBf!OcqiC~fKytFZWl&mh0$F3w1n!CfrmD3htCLFx-X3eV5&oG^smNVB_*#aLQuhC#9@@k9MccBwshe5WpklDEGAx%_2Y4CBi3ne^V*BqRGlxLR^OnW#tD-6rrv6ivt_4NOvHOUyC~86TQ%bh4QqWJ-ali$Hf@OV59O67I#ZdBEp6vA0hFJm^RIk6J3!Xlpqdhik9mcTPznjXknpMW*N4&nVT~v5jVW5-XY^b-fzaUNh5Ej1QaeaR#d*nEzyUlXTCd7%im_HByP30gNr!aX69m6Tsexd*oTk&6COPu0TM9F*rWqUXTgR^S9jF3JcFwZ_BXZ@i4Q61*6rm^rQnKEFSGV4^f1^E0__!LqIXT^v^2%Wj*1HHhCEisrsg$sfoz1G1JcKhV!B^q!8mz1c8CWg~*cC!FRAZQTz@OiUyPf0dbduR#ydk0DA@d_YCB$$$dfprxW0vmEY@z56s22$BsF80HS*n@QpclLKJX8*OPr_Oj!tUpR4TV1^_DDYG_#HAFBo-vX*IEQmn58HV9AFjxxQ$9H1E2NsbsGzT50d379K6AQ&#RE-KTg&hwdnS&K*-qLcrdqX6@RN&-a-%D@VNh^7r2Eb^oKJ*kj2apNKnc^eJ-kUb*YtjMyK^#fyaDsMBcp*Qk@bd-Dm6Z*TgGJF2KPBuyQP!shfB2%qQk__b~!cw3$U&eIOZceV5wQ4u@JtTuB9W6RqR6ZqA3Ct4PNxw^z~aEB!wZ_ldow75#VWM407d@3m2Xntl@@j9NWRcR-KgqWK8TaVf8F_rbz1O4XYyy4Vg%QnPQcDEZ_$pBuaIv^vHVFKYVzKFoULJp!UyMKog2mnJQmA@IPis$F3oTL^RNm_45&nb3Y6Mx$OXZy&8c-7C2CLpv2m52LRb^JcaCJH3eoB0AyKhCQj%isfr~s*5GJ!jjd!lzxzW1qTS6sp2jn#s@UFYxoz#&@Pk_n&dwR&5^aZGADDfNRQV0_kL7_ECmaXsKDfV*J*pFBr@*tui4ndv!rv!KU5FsuT#nQJnOAejYweHIoq#Z^ML~LXh_iBEs5!4&ej-^DGPzpMyEtB9y_~Hp4%l&av0lnXeYFQtA^3UZ4cmw0UGo9QjOQyN*-JBOZdGPDWpmEuFp#F5W!4^OOdzmXw~9%Eg4WZkFJ6&n^8NVguUkm92eUP6X2BN#X0@A$0U@D^OZpq&BYL^iFm9u4nO01gyRQTJlaenYvZZEV!3%Fur#Y6CISyxq-DuxMQL8sw1qPY~^yBO@HN-@4JKJBKOIFi27JtZ%@p$hFseaWW*prM&9!5IBV7^xsv@BJVJ5YYS#5OEd_qtb8edjytgO8Sy4AX16W2JmoRr#3dcjmxmVrvvPHy*Ouo0WAmo559GcW3rgH4jFi-_UH6lJN!_sIASJYvEI1lSm!9gDylbqc#HJjPJm$4b_fl5aN5jkB0Zf^dB4vEFS0FatCHx!siu6XJEl3rkM!I&kqmQxTxQN3nuZklhifiaV^&3Cj8RI5CVi7S%$khEP_8F2_@*YJJXC5Ng6oFBF7@~Hk$hDG9dh7MirrW7%PMNQ~alVwn-Hj%OdtnlLKliK_t^yimq-DcV$ZgFVH7i%&jvcfD!!LuN8kF6h%OG5-_~1gmv06xovdUiqk!WFl^Kt&RkEVBo1WxtGQKWR&ulcWxVYmjn#sg%9v76#f3tNc!C~^zze&igwVzDwgbMlzV#skwi!kBn$nnfXV8Dm9%3D@0Dp_3G2!*kr7@tiJpM&P_j9F2FK2~YVrDHPHkjZp!efZif*0oE3NnQN5qCzsZ#&!hB4bkrt&#igE#yQKsANt6oG6$lllQ09UXmA2X48nV@RICX3I6AhmB9Q1XKrEUnaj^0SIs#0EqU_KCWWEAtYJg$a1da6laTWjrF@-m-%rl5F8H23pYRvusRkdgm~uD~td7RHVPg3zXXrc76RfdSS8aAsSBmjm_4oSd!^1Io&lkk&M9pfPmdteYzAqKw%cb*~FY9A%3&-1uM!oi%W1bUzoxBWpi$ljTXK4&Pr*cA2qUFNkZW1jUAlphk9WXF1c&!eYuTD$*_JV#jL%2yfRG7aZDFtVbersd%iFJg07XtW_J74irWzW#ft0sq-NW@v-DTg$#s~@c_xeXPFJfyu^8ai67iTcTpp#V~EUeKepk60Cs7RxKLz#mdhbdma~Hf8PEj1Y1Azwfl9HJoyvCUfENVYAWVZIALy2v618ZvTiQCO18*$96y$NP_LthuS~Z$1CG1%AGjpqXWqouhktfz!SPoVw-xr6rpRh0^oxwjaTup7No~H!o4*k-YsLlW2WQ0jTNh3gXm5NGrz*3M$fjjEfJMsvS7ARActzuzgAZm9_mi9n%-vf6zCP@NZV%1CY@bDVd@7u#6bK#1P$k5*XRB4Wj5L0yC#pKY*W&rv!jGUQ%OsDo~MbGWCPuYLIZaL6#sX$lT9p1c9g-I5~xCr%!@J7IbX#Y_fy_WBgZxjD21H213Y#*A!e#s&u1@Gx^R@_ngUUZg-iWDmS~$T8VA*sWNDv~F$G2xvFTZ%Z#DGrh4xLMWg~fSt--EOhi_qWk1xSnt!9AFAoZOBY-A5OIwJ9gfRUzaMR1Yk$0irw-2mJRzrHbP8d2CcI~CWkJ_g8hkbdQQsgheWe5oZHWN8S!T5q00Nw1lCqnx7MhzZq&Oi@0UBosF%_aIvQDFxQH!TmWimN2EZ%0#5@mJ_FL5J^0!ZahIbKfb&76dT4Qs*uvuNzwb^eIgcIn~AMhuB-rNfFhjQEm@YmAcCJzzgPBag#yNSeBswux&^g1WytSp-a6z1_0gr3_k~eYADTNCn6KeB6dVQ*im0stw-HtExVZ@0MYEUJWNqu8dC^AJ7t00IP1Fuf9go&*fNvrk0fK#KUBEOkTdHstq_QeOvtmE4bI@@odg1EUDJQLRY~cCGNiFBXVBHp_Z3J~sNXkH_~F~&6wXVLo-dM*ND_q!c0a98J&NkbbuNdfVq4T@8es3qLIPF4*RV@vYjR~NkiQcaBf1CIOY6lczE%fslmjMJKca_x3Hovtd4oPGhOOEwm63TFx@YJMn@21*MGk_60-8-jOi#@qytjxGrdrewet66!Mm74DuxtqQr!2@ku3iktxORbE7GKT~SCZWj0HL~$EQY1c5vYduZ_77Pqrh2Mfru93KcYyE$-QZfMzxBwrKLfJaHh!uXZYfSoTv_GFYYTENDGjZAZlWOc81g4%cPTg_fQ#-JzzS0NqO4nJ1jF95yJ42K8D@cd%%DCDZ~MaLJshEhnmEpSv_y@55M%OWGorW6PjOyC4r2!nLbRFSFdRdu2nUT~^o3OE7PIxt1#tZjjMT3uh9ZSzXxlUrmKO*&q@D-RNnhdNmrQk1T!uKQC^xX!~r2lh!~DhJv73j-8yPf5g8b&62A0tDhup0qCA7ITH3296H4ZvBLBKWwRZT6MgYbYdXASbx!A^9tCWPHHnBpP0SU#rht1gsC4xk@T9*Jd9BHNlquFfQ5NPU191lZ22pUu&G~nPmB$zW9K4vbOcYqV8cp$ShUOu92KW&K0fsRkckkSNgJNE3AJmBmMtr@TuADy-OK&^G7RNVe31nqD*bsvQI-~hcb*CnVuf!HMBFaxfEapVsAMTMf*qGg5PdzKUMDl#xSP*n9A@9%ip&eZmk-605hHeRHarPgUdQEqimpzsRZL0^eEoNCxx*5@zTXQo@*kbuh0c*mybPBEh1tKi&kJi76hRqqaZaP#tBsnO2rzYM!MHa$QZfs9Nd@VgOBo&7tvvUQtGhTVUr&ulSGG$d%*bXQjzyOfXmAtho-SH*2!1#9hY6dQ51eUtOMcKNA6-9OAI_gmPBRBuKRKJyW2A6Iyr0vbkp-87O7_lmst15jDf&0_M_smSAyvgL%n-q3K9AVpxLghgld9$iMfrB@d8N&33wmGDcXt@NXk~7^&LUuGeT15DauP&Bj0014LOg$vkm9U6P#PyL@m~Qtbr79ULoT^hSJBMZWWzjp$F-$%mY0EPghUAtYlAeq#^II4_fJNZmJGc%d-h1$Du^n4uGa@**0uMX_D@sb3Pcuzb3tQ0AhZcQNa0iMQc5DOV~yF6VJ8&qhnVXh$68pTO4qxrW&LNDbex%_LigONjXNPuPbsbbWUsUe1P^YbKjpy0JatODmHSNZ1IF~AA-uhc6q~n7UH@tATI%~qkdFO4ch6onp8F%&9-wjUAseD5xlG138m~5nMcTnE0qaHx25bOd~emMw3ZqSyRtxn~9~ACCd6sl4wOHuzQlALJZBo^yZzcx_lyKt0b^CDBME5nF81wwTN39Zk9qL*F%&&nBO5xq&uNomBszQsKcQJx*e2G99gK@IpzQk!1%Eoc!oV2iex@yfcl-z^_z!aS7aJDVm%r28o3LEDn-yQvv4i~r43_O4!LcNJIKvF63wNyhiM4EAOHmkjs#i^r3t1#7MjaU^@zLFhCMo1*5Y2d&jjZoqnSrEj^js#~TCWbKi#2xrZA4m&~u-!z~15aX4c8E7qhTle5-b-Yz@Q88i&Kn0Aaa$*sQg2jBbXTZg6XzD-2Pd__cg06AQ@zE#Obwi-CdtpvJW~g7SEaOz$88D0RQun4d~F7k5Yw*CstxYJseCm6d3r&NuzIh&awT2hWw^qPuCvIqHrx~l^RBkTGOVRPdrJtWqALQ@NIc%480&!02R92!vNEyOfm^M&4BoAGdpxo3Y^Pg0R6h2MX2_MHhnwBYvh20c72D$iU-UC5-$3S%yJQri6@OTXcz7HYRqas1XQrE3dlm%7MZY!pKXJ$TmDFzTlKoHS8JXdP*oFyG1Lze3P_jHgidsQpypp^w^*hf~EGXsD0I0@$IzFj3-_wKp%xRCy^O8oKs5kPJ~cT99zBBkl^Qm&PGp&YtI3Y5rP7#Y8qdqeD~3f45QVBAo-S!m-BIBfNCrTr~UJy_POWT!$sW~DZrH41aDfTlyhNyk2HI0Ks%vYs2ixpat%mjXcpaOd2O&WS1@kPE!8#eJDU5o4%VQUz@%f4ivTqk~zY4zBtUO2XRp_~RbbpqS@^yCetTy4_X8&#t1H~f406Z^*Nha-PTXl8cgXXxWpvkCf8V9$AiG%igkk2~WYF~$VLduZVGB-6F4eEjmuWc8JJnIx$k^dA-BFdzvhpc-FI26Cdr7ljwUZ*zhdNRwHqIlBLdWXYbocGAZxaFR#TeKi^1cB%1S*ayqup&6hKu_mBSqyWb7rLSPI3GQJWjVSr~UO*CH%vf@WKH~RBJBv_TRIFPz^yKA$DU^68*yoK4UusI3z9Ipq3zY7OSjSefJ4mMTzg~BKDEUhffP6nIFB%M!6^qfu5Wpp@coOGEcIuG#~tUf@VGQ6vPN8CY96ZHE6bnbJ@&!^Mm3Ouci3Yv*eA-MvqP9Mes$xGe-vjEc4^zUnM*iO$J5#Vdsj25*FyES#~AkVQszi9Lgd4OV2ztzSLdwLibs_TF@uQ!*KM7xS!&ty^6DCkUpSdtJP7bW!7mVPVYOTt^1~VI9d^&qetbkwkeIjNMZ4nzDl$4eIvDhE7vd@yNy7fgaLEfVuS1&_1P%LzuqRpr%t*oE*rCr1LQd*0Jpbh1f6-v48rHTe!tfcGFXGT9XQY^ZKH^qxEZ&uW38sgd-5R0KCjb2S*51-itFiWFM6ZyeQu$G2d3Q$j_~0fU8p8e-yMOzkofq!g&i-NPYgZu5#3DZLrb0~sXyY7sCEqaJ-IHfL!&hj~H5D4WYcHN%6rv$@Zl%3VuD5m!frKuZVEP~pL*&rZbtDD^YMp7q3Qo^WFzZRdT4QItNsHqe#rPTI7wJ0U8bj8YeqL5y&!pnKVnDUFmrP$3du8nZsT$M9YtVD^$pVdc##w^ksSYmSVd_Ff1$w&P4Bl3&t$4HZ682!oGc4Y&jVwkj$d%OlhotakyprCgCQebIp~$m&5k0Hu2lG~xf#t*n5~sGf@51_owF1c1PKunwMc%F%Qcjs01H1!mR*bEN-0O$UN3vznFkaMuoR5zF2#Ct~6aC*Xk*w9Tq&ngW_#airnTLt0WG8ReiMyA*s%*nD!7&u*4_CW3NbRoJVpIUCjh6&uV~1bxp4EjxmOZ_im~zwXvhCrjHVZ*paoaA6c686bgoUpz1$#uuEwxEQrRvjC@x^W$O%GE_P69--RR^ywwz01tTkeaHnp#zpY^McD-VzL78QLRr0Iy_770sa*i^e1f3x8$LsQo591foQ-!4lv~iVFE&@ee@8#oftHQsCxy7J&9g-9jZe!8xCTyM73p2LQnegsXlNcmgbLVY95WuE~y*sRLhVhR%VEheqEfdO^poi1Qhe0xK_1Q3L6B8p$@ew^9udr1eeVJ&edsOtUO*n2T!Fq9qsD3!sB@K8PxDj!$W~^JZpAm-5d2zt9ielNtuyv_V53OWWSypnr0z8hRuo$LKlpz~eMP~zLQ2sc!QxI-C_W-!&$&q#r6y8HvyLM0By0#*w&vfdRN_LsvmOXfpnRF3ipU@sWI5_-h9rP0mdRk#QIB6VeNOmRB&G31-1mUY-HYeMVCCuZovKf_FVi#z0dgKrVYnCHQc19VF@Fc%QYuQZegqVMwilt2cbYHtfg*11vttIC0Hr~Z_Z~#uNdkG#5#A^&&xMneHLH@MrU5H~v7Bh4qcvn@Gfw0wH&vWDD0r#BY@&4to5Bp4_rY0WL30!dXEIQU1zoz7k~psB-Ko8PADG4lY_gKXS%pDM!mR*9$$35rbS2Kgz2XoK_~_zzovOLa-V7eKN8B%4oxK278MI8MoES@_S$KHuJaMjmOGD2sXLMsmT2q1$C9MQh!qsilCaTSWzEURTOzf2LA&!Wr39k5Y11O!xjPcOjVUdf54I1vDS3cR0-*S$U~tLtBY_SwS#QcDGYIIGTODWUAuXwwzaFZkIcJ5~~&GEsx*fAwXqzTRNW~vlEj!VnUB5myRa9*mUOO^I871#J68E&dzp_HBeP-E@P9nzjC**j&wa6XCC8MvDBoXYDj~oqisy~RhpBDyU8PjV&Rdk4$uD-qD6J$jXK$rWgsqricjKATfBnhf5Td28UWQNF1IpFmu2GraHH5EnT$J#q&LH2xeiYLX6Hqw^W#*$@tk~fqOICUe7a_DraRdiUE$2TlXOmomz2QD_m0@WZ&zMrZg7!I38Eu5snvCcpa&&e8ZKJnnE*RY-_O#nIY#~4FI3-e5gn&@%jM7zW!eNx&&4g6U5v~jXpwgy^KS3UcgWyS5u0w#GC3cmPtQ9^QBgXMpHppwJ1zf0-mD@Qd8RPU^CXwG!&fBTCVS%zKa1-fDyG6*&s%tuqHz~ky2cC2S5H^Y#U9iY2COvvbDjWPo445bcSl6ku-R#yo7u3#Rx!Xl0^$nBcIEHW9NdP-_eqp%hHnFmAhLaR-P~Ox-nHd#DICdhSxuFi^LX*DtS4O2EJzgb6bi*6gq*LIJ4h^4FCpvI-RFUf779QsVZXNf%3QJO6aFK~9PX%%kkNKMP0Nc#Mf2^cu*46*ni!E*Okd8La%!JDpKs2$wx#0#kuFkEh#k1Cv5i*#6ww%KnH6oc4B$xa54Gr!!&kW$R_YVPajOjTuTQz2^Hg^MYE2NWEG4~AYs#JY4i1FJEL-kKXjg#zx0mdyX8KuBP^@#s!1#1-N*j_oB2TZEC*fTnA6pu&areSJpgOO~uQKAcyURV&$cp^SMe$*RGILWlth7S^It8Vyp3_xKq3yrbOKjfiyd$sQQFYv&4JJ1#kVCZ4Ihof9iZa@iG26eWkc!NrPwEjqO8*N1ZvriP%jGyX0hsSiT%xC1Vfy~3uyjDMFJiY*Vc9*gC-P^#Mcjm%2ohcvNhME6fx*4f9fThRijQb11J@MzqHpcXHYSibWJrEiBSouw$!98PoEfWfJs_xejA^TL7h%Yj$6GhLCvs30BvprqB18^OnH3J%w9IfY4HUR@6W_6EmEVNzGUy7@HeNFKIJy@6r*W6l!jslHoI*d8hpY4qpFQWWQFM09Ev6#rA7#VXfWiKFq!XfhSTYG~a~9Zjb6*4xbNIn9o&w9mM4zLouHO6a^1z*k7FyQ&EFguhgwLh7DC@6pAGVOw^bG#h4&aYqyzJX4XM%xPB8a3SdVZ-N-#d9doRj#bBAvUvydxcsDEeML8YRauL4q0p-JLT$n#t2oXT93Ge2b_52L67455~*bcH8aNG^7AqdGPO_adaGHgbP^3A8^5JGyiHf&PNQ^gbDNgvk1Z1JM8_e4jXGy~6Ja3PNIahqjbqreWUtb66GfLMr$HLwbfUAI@@boQsHQI9x4Z_Zal#^u-X-n_x-mjW~*LH-bs936MmqRvAuOcM~cXSNiZl4&8k9CN!gH_5hv*Ok6*ZQ28s2bq7*RE*zLw_TGJOBpKBqsDr!_YNHwRW8~K_XKitndhpo2P-hiMW9kftc2~WgJPyuoY&f6y*hjg-3aR&zA#eVlgE8#C4uDB7*oRx!o5$F@-AkAZeqENIW*QIu5LYSyhgOu&b30i7lERhtmJLRJgfQ90yiior7hIc_QYnyWNOX@Yd&2*wM6Tv3R%MfN^foK1QV4QOUWA_**rH6AxMAbrrB2$xgQPZl2X!x~7z4yOTsHm0uV~mwU3cOn@kxVNiaisdSAEYBl6JQ_DyXXpe%O!2ag#IxYOXWC*S#BURDR0JCHafpJ&4AWtpsxo4dGoaNZtp3J$Ch^m$~g#r2u$plnsU4ruc8HfF47V1mxllNnfyRqF3UX#~h_@mc$1DoVyPM9PH_7N9^Nz7n8bKrSrqpNyck@4Yv1I547vhzateMH!4W8%ugbqdN0&9A#nxwchqZN2qhYMEk#dD0WOug#yhvrc-f&c&4O7v8nmcvm6-3cOPl2P5PcDc6bQixVNpvp*4IALVWGaWk$rTRT8U2ZYWSkkL$BZD--&F%iIh5K0aROVIjCOhU@k0-@cXeYx&fMjw7Md#kBb6CYg#t9T@k3PQY7b7tJlse@qWlf~knivM3sx-II1T4r_VwF#_pJOYPYJqTav19hhg@pIIDxdMkZc3Ig0Fgr9B9VgpweaspyV5B5PfBb#p@8SGE$hP@g6TVjhh5!IIafDx$L0NzYOii$~ZRxxBfeS6uCnIq5vF!iiIgdVhkj-z-nXObVOa15P*S9$5yMKOUQK$H$zIENk30%m@n3svUY_NBnY!gtSCJ8E4IKGxlaFPdsxZ9Mhzzy%p!wz5nxr-zk7AaV@Mz#7rCRhTnsXCs5YFST7TWO~s_E1jVC^^%~G7xiN9UoH8@tCn#*dmbHXxiMuCZau_rFHehKN2Ke#!_V_khRSsxTdAK-!FcmPxa%pdjV~3U5H-nzR*CMzPBN#wxd03t9y%E~cx1~$5g!2JTv^Ahh$EraTDW&J92Oi~XKxKjuGEiPR^x8~sTo^yOqIy9&f#_m8cQrKQsSBaWSqN~!w#H5gipIx@QZqQJ_ALspwIlrhjaK%Y46iAgbze$J2x6M^HPuLJ^QztEalfY@uJz*o32WrJhX^A6A4i@SH!&XXxK7JIJE5NWaZOcPhzXxOFc!BsdAKJ32Yp!E1QQZLNeZNvAlOeN9Me!Fiq~-YnUUVjSYLn*J!DSDOO$t%cW#Rg4lzaGzK9ujasGv^pbaL%wyJmySxcyF1N^opMc%O!502A^eflQx5p~Oad*mQ9QbkSS0K60N4Hsfw&Qw7gpDKMpjW@Wz1ORA65ay_O9Msli&sQY9L#0$JTxD^Y~OX0axs&Wlf_xLTUy4t5%q0EG3Zj#q_NYV4wzh3^r!BK~XoClS~C18aYiHiPeY$-Y6YkF$lHz8sVp#so42T3YcP4tEI4-_FHVIwZ-g-x@p$YpCr6SwZU*A2J$2!$GHskFa3Z#Vh&m8RK1J_e96Q-Eq63vt7dGoZHKUT0Q!jq5Be_$UMK5Y$EDcaaGCXWaTH0EdZ-tj1nj_HhRTU$uH^hj_0GHaRE6yz9ftK%@@9@*Sn%*6z8G-yxjr8M1S5kwcK_HdZDzn-eMBJcG^h5qEGOo8DK8#H_7pUxvO_rUBoch0en15*NY!jHXM9wzLUVdqtmh!*L^KKR#8qrjJ%PxDgRB1BXyp$UDHofwmfMS!OzkfCt!bVfZ84d@nf#G&h7L!1S!@spMVXa^EuNv~f2H5Aisni6ZPJd-bb1*QHI_&Xxiy8X5ru74I2VeF*kSFE2j%!l7fDRNib4R_3q$sVHmlAjreB^4gp~kviNg5JB5Q5sAqA#JpsD#qom%QQr$FdR!CENmOojI&XaNd7rO76RL^dh3Eiq2CJiPc*3ACLYwh*KZMBbfN6VurMdOxTC#nfZPiTckaA8y68AszOd7YbPZpL7vPlx4rfs&mZjDcR88WV^DJUpHU71rp*Pw@SHbp$wG3K*m6nwnT8-ZO18qOY%nfL8&%E~2#EzYa$JEUhxLXN*aVbvcw920oO!VeTh2j1laX6v%XX8DLdRH_tHbpyh6xvHl76WseE#KB@P97o4&!uMkDdhK~raDnUOkmUG26#3@uZZV!jLcIHG^dASqH~mpThk&Y$PA6ZFuIa7S5YWrVCdDSb*kAo*BuyzEF&N&DD0F9B7z_P%npKtT7LQGdR4Dkl5MTo8Al37Qiv#VSfxuKop-GmTMz-ZMTPxf7-O8SSbUgyNkt8ZnBhx9oP3wNuqEB9cI-w%~3aZiUaVAvC#-v_N0u-V&FR6eaJMAV3PoZcTRK8CjMQiKhPDZ%XnY$3qR&C!OEK!JUebDgys80ZLZ5nWxRVgAXuK$f*I!#bYhXGzQ5kWLDa*j5%Fm_T8&Ux8n*msB^NhpPK@1avNLQKJa28PH%Xd39#*Y8v!s7IMAhFvkUFO3ly#2lmwNvIvFmRmF_@NVak6iMOmXs2kS$76BevXio&A8j@8tC&4UrYPOaHzn^XCAjct*_&VG-y^FqWp^5$s3A61gMkQ6!@-UqwESXe2utCOU~Ain%XdUQ^YWfcCgC^b#6Qs$IUn1gxIL72ckNbW5&yRRL9&%-GImO9wmrn8WrfVvZKiipmlfS0TlYTGbDwzJl7VFNn&4ytFjIt2S2v96q%Asey~dAkPD9nqt^UdlUCML#sNTNonjCjH$8BmcovVsT8Ag7vANkz-ww&SJY6~Hk5CexGxoIc!RoO^CV2rtBU~wS&PuKPX7iEs3Vepl*vuiST4u%y7ItR2o&y@%KZizVVdj7-o2U$peKyYZmM34S!lly#$bzSpzp%OC!tj0RTkR6UQq65u0#Mjo4VscxOPi0TCFeJnQhaM3tTIVB%@4Y6Xkb!9o6I1nuFpcWWbrO#0%B6Ov~GDHIosyv*q$Iy6ru6*fwj!ba8bCRgS%gCGTD%zdHS#Qp~zkfG*1wJiIi4u14i$Dd*si7!1VblHC5dJfRxMX_4&8f0&4WF3i1*orucNXS2WAQiKdL~3$U*FIm7Ky^XuIE^zpP$*OoGU2@q!yfHASsXTRQ2~^mQObTvNDJ*5TTMJB4R_YoMltN^x#-Fve85nG6a6-#zzGH@YADN1gU6zX8EglgznD2xRGv5gdxtIgNUWk89r~r!VBEA^MH2_N!~Q^p&hvWpsdkX$%mMQA9DZ*$uM5vzx89Pp^MjQalk*R-Bf!3uH4Nm#B@th~cuaVM&zOrsMuMgxt~v2Y#7oGhhoq7pKjJN@t@&5*V~rQCmV0DeY$mS1-1V$dtqXmVHMzHwct^eMODO^73B3NMJDJXGJ@%GQlEl~3f_P4l_$m401~w~wn4mPHJ%MKctx43vVlN2f@x3fFAPKBq0wSQ3MEp2~^#zUJjA%Msk-h3^CiGm%!^e5QklYqlwAVBtsWsI9sG%9jB30Ey$z-0H@13ngj!g$u6B66yx~Do7$v6g8N_fBIzZXh3$dvD9mt%brJso~AGiHtHgFS0JF*X&!9_vk5nJtOJaBQ-hHXiNAa9ooaBSX2EDOZP~3bIcUeVzk#Bi~9320M3_^4nRIPp45c*7Aqy@Jjws*!WGv8ha&Hw!tyJWmCWlH~DIg@HrJZnEqNnD%2Vu%!4mDULqpFBSotghLFqyZiLol7GmIs7qzj1jjakhgF^$MS%ia-FVp&~UJv9h_XhAIfsNslM_P4OVWqn9^o6VJdRZL@MYq~*cJovrVWPW0k0b4aCgWrGIT5Rn$ogfs*%OUi3&Ful_Rn#gh-U85ynsEe5OAVcVCiqWgL#SFuyR$xw&kl@yLagO-Ri2mp$~uG@mcLQ~wmQ2c5daujWV229Cyi-6Rq_&qL##FITesVeh18OqOHsDW!BHuclR%e0x2-%LYu8u8H8U833^jI2CKn*NJSAthYHffK@t&fEd~OQ&FSEgD9sGuf-#bkPJYqBXdGWR1vRX3Yhwssn_%qd~khls6ff#j@FDjtX2cBvd-UP8OcI52DG3~ZJ*a53HBT2AETacG5fODRpCEd#o5e_%10Yd^pu%gt&$9DG!k8SZSds8I*R!xr diff --git a/internal/media/test/test-jpeg-blurhash.jpg b/internal/media/test/test-jpeg-blurhash.jpg deleted file mode 100644 index 6b6ba472e..000000000 Binary files a/internal/media/test/test-jpeg-blurhash.jpg and /dev/null differ diff --git a/internal/media/test/test-with-exif.jpg b/internal/media/test/test-with-exif.jpg deleted file mode 100644 index de56cd654..000000000 Binary files a/internal/media/test/test-with-exif.jpg and /dev/null differ diff --git a/internal/media/test/test-without-exif.jpg b/internal/media/test/test-without-exif.jpg deleted file mode 100644 index 274188ee7..000000000 Binary files a/internal/media/test/test-without-exif.jpg and /dev/null differ diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 9c7f0fe67..5a9382ed6 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -85,7 +85,7 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.fromClientAPIChan = make(chan messages.FromClientAPI, 100) suite.httpClient = testrig.NewMockHTTPClient(nil) suite.transportController = testrig.NewTestTransportController(suite.httpClient, suite.db) - suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage, suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) suite.accountProcessor = account.New(suite.db, suite.tc, suite.mediaManager, suite.oauthServer, suite.fromClientAPIChan, suite.federator) diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index e0dd493e1..6d15b5afb 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -172,7 +172,7 @@ func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHead return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err) } - return processingMedia.LoadAttachment(ctx) + return processingMedia.Load(ctx) } // UpdateHeader does the dirty work of checking the header part of an account update form, @@ -214,7 +214,7 @@ func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHead return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err) } - return processingMedia.LoadAttachment(ctx) + return processingMedia.Load(ctx) } func (p *processor) processNote(ctx context.Context, note string, accountID string) (string, error) { diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 093a3d2be..9df5c7c1f 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -60,7 +60,7 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, err } - attachment, err := media.LoadAttachment(ctx) + attachment, err := media.Load(ctx) if err != nil { return nil, err } diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index 04793898f..851d3d5fb 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -47,11 +47,11 @@ type ProcessingStandardTestSuite struct { suite.Suite db db.DB storage *kv.KVStore + mediaManager media.Manager typeconverter typeutils.TypeConverter transportController transport.Controller federator federation.Federator oauthServer oauth.Server - mediaManager media.Manager timelineManager timeline.Manager emailSender email.Sender @@ -216,9 +216,9 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { }) suite.transportController = testrig.NewTestTransportController(httpClient, suite.db) - suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage, suite.mediaManager) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.timelineManager = testrig.NewTestTimelineManager(suite.db) suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) diff --git a/testrig/federator.go b/testrig/federator.go index b9c742832..1b5e0fdc5 100644 --- a/testrig/federator.go +++ b/testrig/federator.go @@ -22,10 +22,11 @@ import ( "codeberg.org/gruf/go-store/kv" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/transport" ) // NewTestFederator returns a federator with the given database and (mock!!) transport controller. -func NewTestFederator(db db.DB, tc transport.Controller, storage *kv.KVStore) federation.Federator { - return federation.NewFederator(db, NewTestFederatingDB(db), tc, NewTestTypeConverter(db), NewTestMediaManager(db, storage)) +func NewTestFederator(db db.DB, tc transport.Controller, storage *kv.KVStore, mediaManager media.Manager) federation.Federator { + return federation.NewFederator(db, NewTestFederatingDB(db), tc, NewTestTypeConverter(db), mediaManager) } diff --git a/testrig/mediahandler.go b/testrig/mediahandler.go index 046ddc5be..38190eca3 100644 --- a/testrig/mediahandler.go +++ b/testrig/mediahandler.go @@ -26,7 +26,7 @@ import ( // NewTestMediaManager returns a media handler with the default test config, and the given db and storage. func NewTestMediaManager(db db.DB, storage *kv.KVStore) media.Manager { - m, err := media.New(db, storage) + m, err := media.NewManager(db, storage) if err != nil { panic(err) } diff --git a/testrig/processor.go b/testrig/processor.go index d86dd8411..cc2c3605e 100644 --- a/testrig/processor.go +++ b/testrig/processor.go @@ -23,10 +23,11 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" ) // NewTestProcessor returns a Processor suitable for testing purposes -func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator, emailSender email.Sender) processing.Processor { - return processing.NewProcessor(NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaManager(db, storage), storage, NewTestTimelineManager(db), db, emailSender) +func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator, emailSender email.Sender, mediaManager media.Manager) processing.Processor { + return processing.NewProcessor(NewTestTypeConverter(db), federator, NewTestOauthServer(db), mediaManager, storage, NewTestTimelineManager(db), db, emailSender) } -- cgit v1.2.3 From 113f9d9ab4797de6ae17819c96ae866992214021 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Tue, 11 Jan 2022 17:49:14 +0100 Subject: pass a function into the manager, start work on emoji --- internal/api/client/admin/admin.go | 2 +- internal/api/client/admin/admin_test.go | 123 +++++++ internal/api/client/admin/emojicreate.go | 4 +- internal/api/client/admin/emojicreate_test.go | 50 +++ internal/federation/dereferencing/account.go | 15 +- internal/federation/dereferencing/dereferencer.go | 2 +- internal/federation/dereferencing/media.go | 17 +- internal/federation/dereferencing/media_test.go | 20 +- internal/federation/dereferencing/status.go | 4 +- internal/media/image.go | 108 ------ internal/media/manager.go | 81 +++-- internal/media/manager_test.go | 32 +- internal/media/processing.go | 256 -------------- internal/media/processingemoji.go | 382 ++++++++++++++++++++ internal/media/processingmedia.go | 411 ++++++++++++++++++++++ internal/media/types.go | 12 +- internal/processing/account/update.go | 95 ++--- internal/processing/admin/emoji.go | 34 +- internal/processing/media/create.go | 31 +- internal/transport/transport.go | 2 +- testrig/testmodels.go | 10 + 21 files changed, 1164 insertions(+), 527 deletions(-) create mode 100644 internal/api/client/admin/admin_test.go create mode 100644 internal/api/client/admin/emojicreate_test.go delete mode 100644 internal/media/processing.go create mode 100644 internal/media/processingemoji.go create mode 100644 internal/media/processingmedia.go (limited to 'internal/processing/media/create.go') diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index f8ea03cc6..f5256c996 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -58,7 +58,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.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler) 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/admin_test.go b/internal/api/client/admin/admin_test.go new file mode 100644 index 000000000..da5b03949 --- /dev/null +++ b/internal/api/client/admin/admin_test.go @@ -0,0 +1,123 @@ +/* + 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 ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + + "codeberg.org/gruf/go-store/kv" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "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/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AdminStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + tc typeutils.TypeConverter + storage *kv.KVStore + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + sentEmails map[string]string + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + adminModule *admin.Module +} + +func (suite *AdminStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *AdminStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager) + suite.adminModule = admin.New(suite.processor).(*admin.Module) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *AdminStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *AdminStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { + ctx, _ := gin.CreateTestContext(recorder) + + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["admin_account"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["admin_account"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"]) + + protocol := viper.GetString(config.Keys.Protocol) + host := viper.GetString(config.Keys.Host) + + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + + ctx.Request = httptest.NewRequest(http.MethodPatch, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting + + if bodyContentType != "" { + ctx.Request.Header.Set("Content-Type", bodyContentType) + } + + ctx.Request.Header.Set("accept", "application/json") + + return ctx +} diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go index 882654ea9..de7a2979a 100644 --- a/internal/api/client/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -31,7 +31,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/validate" ) -// emojiCreateRequest swagger:operation POST /api/v1/admin/custom_emojis emojiCreate +// EmojiCreatePOSTHandler swagger:operation POST /api/v1/admin/custom_emojis emojiCreate // // Upload and create a new instance emoji. // @@ -73,7 +73,7 @@ import ( // description: forbidden // '400': // description: bad request -func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { +func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) { l := logrus.WithFields(logrus.Fields{ "func": "emojiCreatePOSTHandler", "request_uri": c.Request.RequestURI, diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go new file mode 100644 index 000000000..290b478f7 --- /dev/null +++ b/internal/api/client/admin/emojicreate_test.go @@ -0,0 +1,50 @@ +package admin_test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmojiCreateTestSuite struct { + AdminStandardTestSuite +} + +func (suite *EmojiCreateTestSuite) TestEmojiCreate() { + // set up the request + requestBody, w, err := testrig.CreateMultipartFormData( + "image", "../../../../testrig/media/rainbow-original.png", + map[string]string{ + "shortcode": "rainbow", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPath, w.FormDataContentType()) + + // call the handler + suite.adminModule.EmojiCreatePOSTHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.NotEmpty(b) +} + +func TestEmojiCreateTestSuite(t *testing.T) { + suite.Run(t, &EmojiCreateTestSuite{}) +} diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 27d9f39ce..b9efbfa45 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -119,7 +119,6 @@ func (d *deref) GetRemoteAccount(ctx context.Context, username string, remoteAcc } else { // take the id we already have and do an update gtsAccount.ID = maybeAccount.ID -aaaaaaaaaaaaaaaaaa if err := d.PopulateAccountFields(ctx, gtsAccount, username, refresh); err != nil { return nil, new, fmt.Errorf("FullyDereferenceAccount: error populating further account fields: %s", err) } @@ -252,13 +251,12 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data, err := t.DereferenceMedia(ctx, avatarIRI) - if err != nil { - return err + data := func(innerCtx context.Context) ([]byte, error) { + return t.DereferenceMedia(innerCtx, avatarIRI) } avatar := true - processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ + processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalMediaInfo{ RemoteURL: &targetAccount.AvatarRemoteURL, Avatar: &avatar, }) @@ -275,13 +273,12 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data, err := t.DereferenceMedia(ctx, headerIRI) - if err != nil { - return err + data := func(innerCtx context.Context) ([]byte, error) { + return t.DereferenceMedia(innerCtx, headerIRI) } header := true - processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalInfo{ + processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalMediaInfo{ RemoteURL: &targetAccount.HeaderRemoteURL, Header: &header, }) diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 1e6f781b8..800da6c04 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -41,7 +41,7 @@ type Dereferencer interface { GetRemoteInstance(ctx context.Context, username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) - GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Processing, error) + GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalMediaInfo) (*media.ProcessingMedia, error) DereferenceAnnounce(ctx context.Context, announce *gtsmodel.Status, requestingUsername string) error DereferenceThread(ctx context.Context, username string, statusIRI *url.URL) error diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index f02303aa1..46cb4a5f4 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -26,29 +26,28 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/media" ) -func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalInfo) (*media.Processing, error) { +func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalMediaInfo) (*media.ProcessingMedia, error) { if accountID == "" { - return nil, fmt.Errorf("RefreshAttachment: minAttachment account ID was empty") + return nil, fmt.Errorf("GetRemoteMedia: account ID was empty") } t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error creating transport: %s", err) + return nil, fmt.Errorf("GetRemoteMedia: error creating transport: %s", err) } derefURI, err := url.Parse(remoteURL) if err != nil { - return nil, err + return nil, fmt.Errorf("GetRemoteMedia: error parsing url: %s", err) } - data, err := t.DereferenceMedia(ctx, derefURI) - if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error dereferencing media: %s", err) + dataFunc := func(innerCtx context.Context) ([]byte, error) { + return t.DereferenceMedia(innerCtx, derefURI) } - processingMedia, err := d.mediaManager.ProcessMedia(ctx, data, accountID, ai) + processingMedia, err := d.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai) if err != nil { - return nil, fmt.Errorf("RefreshAttachment: error processing attachment: %s", err) + return nil, fmt.Errorf("GetRemoteMedia: error processing attachment: %s", err) } return processingMedia, nil diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go index 61ee6edb6..26d5c0c49 100644 --- a/internal/federation/dereferencing/media_test.go +++ b/internal/federation/dereferencing/media_test.go @@ -20,6 +20,7 @@ package dereferencing_test import ( "context" + "fmt" "testing" "time" @@ -44,7 +45,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() { attachmentDescription := "It's a cute plushie." attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" - media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalInfo{ + media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ StatusID: &attachmentStatus, RemoteURL: &attachmentURL, Description: &attachmentDescription, @@ -53,7 +54,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() { suite.NoError(err) // make a blocking call to load the attachment from the in-process media - attachment, err := media.Load(ctx) + attachment, err := media.LoadAttachment(ctx) suite.NoError(err) suite.NotNil(attachment) @@ -118,18 +119,21 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentAsync() { attachmentDescription := "It's a cute plushie." attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" - media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalInfo{ + processingMedia, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ StatusID: &attachmentStatus, RemoteURL: &attachmentURL, Description: &attachmentDescription, Blurhash: &attachmentBlurhash, }) suite.NoError(err) - attachmentID := media.AttachmentID() - - // wait 5 seconds to let the image process in the background - // it probably won't really take this long but hey let's be sure - time.Sleep(5 * time.Second) + attachmentID := processingMedia.AttachmentID() + + // wait for the media to finish processing + for finished := processingMedia.Finished(); !finished; finished = processingMedia.Finished() { + time.Sleep(10 * time.Millisecond) + fmt.Printf("\n\nnot finished yet...\n\n") + } + fmt.Printf("\n\nfinished!\n\n") // now get the attachment from the database attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 041cfa6b4..004d648b5 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -394,7 +394,7 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. a.AccountID = status.AccountID a.StatusID = status.ID - media, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL, &media.AdditionalInfo{ + media, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL, &media.AdditionalMediaInfo{ CreatedAt: &a.CreatedAt, StatusID: &a.StatusID, RemoteURL: &a.RemoteURL, @@ -406,7 +406,7 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. continue } - attachment, err := media.Load(ctx) + attachment, err := media.LoadAttachment(ctx) if err != nil { logrus.Errorf("populateStatusAttachments: couldn't load remote attachment %s: %s", a.RemoteURL, err) continue diff --git a/internal/media/image.go b/internal/media/image.go index 074dd3839..a5a818206 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -27,7 +27,6 @@ import ( "image/gif" "image/jpeg" "image/png" - "strings" "time" "github.com/buckket/go-blurhash" @@ -52,113 +51,6 @@ type ImageMeta struct { blurhash string } -func (m *manager) preProcessImage(ctx context.Context, data []byte, contentType string, accountID string, ai *AdditionalInfo) (*Processing, error) { - if !supportedImage(contentType) { - return nil, fmt.Errorf("image type %s not supported", contentType) - } - - if len(data) == 0 { - return nil, errors.New("image was of size 0") - } - - id, err := id.NewRandomULID() - if err != nil { - return nil, err - } - - extension := strings.Split(contentType, "/")[1] - - // populate initial fields on the media attachment -- some of these will be overwritten as we proceed - attachment := >smodel.MediaAttachment{ - ID: id, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - StatusID: "", - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeOriginal), id, extension), - RemoteURL: "", - Type: gtsmodel.FileTypeImage, - FileMeta: gtsmodel.FileMeta{ - Focus: gtsmodel.Focus{ - X: 0, - Y: 0, - }, - }, - AccountID: accountID, - Description: "", - ScheduledStatusID: "", - Blurhash: "", - Processing: gtsmodel.ProcessingStatusReceived, - File: gtsmodel.File{ - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeOriginal, id, extension), - ContentType: contentType, - UpdatedAt: time.Now(), - }, - Thumbnail: gtsmodel.Thumbnail{ - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, - ContentType: mimeJpeg, - UpdatedAt: time.Now(), - }, - Avatar: false, - Header: false, - } - - // check if we have additional info to add to the attachment, - // and overwrite some of the attachment fields if so - if ai != nil { - if ai.CreatedAt != nil { - attachment.CreatedAt = *ai.CreatedAt - } - - if ai.StatusID != nil { - attachment.StatusID = *ai.StatusID - } - - if ai.RemoteURL != nil { - attachment.RemoteURL = *ai.RemoteURL - } - - if ai.Description != nil { - attachment.Description = *ai.Description - } - - if ai.ScheduledStatusID != nil { - attachment.ScheduledStatusID = *ai.ScheduledStatusID - } - - if ai.Blurhash != nil { - attachment.Blurhash = *ai.Blurhash - } - - if ai.Avatar != nil { - attachment.Avatar = *ai.Avatar - } - - if ai.Header != nil { - attachment.Header = *ai.Header - } - - if ai.FocusX != nil { - attachment.FileMeta.Focus.X = *ai.FocusX - } - - if ai.FocusY != nil { - attachment.FileMeta.Focus.Y = *ai.FocusY - } - } - - media := &Processing{ - attachment: attachment, - rawData: data, - thumbstate: received, - fullSizeState: received, - database: m.db, - storage: m.storage, - } - - return media, nil -} - func decodeGif(b []byte) (*ImageMeta, error) { gif, err := gif.DecodeAll(bytes.NewReader(b)) if err != nil { diff --git a/internal/media/manager.go b/internal/media/manager.go index c8642fcb4..e34471591 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -21,9 +21,7 @@ package media import ( "context" "errors" - "fmt" "runtime" - "strings" "codeberg.org/gruf/go-runners" "codeberg.org/gruf/go-store/kv" @@ -33,15 +31,17 @@ import ( // Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs. type Manager interface { - // ProcessMedia begins the process of decoding and storing the given data as a piece of media (aka an attachment). + // ProcessMedia begins the process of decoding and storing the given data as an attachment. // It will return a pointer to a Media struct upon which further actions can be performed, such as getting // the finished media, thumbnail, attachment, etc. // + // data should be a function that the media manager can call to return raw bytes of a piece of media. + // // accountID should be the account that the media belongs to. // // ai is optional and can be nil. Any additional information about the attachment provided will be put in the database. - ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Processing, error) - ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Processing, error) + ProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) + ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) // NumWorkers returns the total number of workers available to this manager. NumWorkers() int // QueueSize returns the total capacity of the queue. @@ -101,49 +101,52 @@ func NewManager(database db.DB, storage *kv.KVStore) (Manager, error) { return m, nil } -func (m *manager) ProcessMedia(ctx context.Context, data []byte, accountID string, ai *AdditionalInfo) (*Processing, error) { - contentType, err := parseContentType(data) +func (m *manager) ProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) { + processingMedia, err := m.preProcessMedia(ctx, data, accountID, ai) if err != nil { return nil, err } - split := strings.Split(contentType, "/") - if len(split) != 2 { - return nil, fmt.Errorf("content type %s malformed", contentType) - } - - mainType := split[0] - - switch mainType { - case mimeImage: - media, err := m.preProcessImage(ctx, data, contentType, accountID, ai) - if err != nil { - return nil, err + logrus.Tracef("ProcessMedia: about to enqueue media with attachmentID %s, queue length is %d", processingMedia.AttachmentID(), m.pool.Queue()) + m.pool.Enqueue(func(innerCtx context.Context) { + select { + case <-innerCtx.Done(): + // if the inner context is done that means the worker pool is closing, so we should just return + return + default: + // start loading the media already for the caller's convenience + if _, err := processingMedia.LoadAttachment(innerCtx); err != nil { + logrus.Errorf("ProcessMedia: error processing media with attachmentID %s: %s", processingMedia.AttachmentID(), err) + } } + }) + logrus.Tracef("ProcessMedia: succesfully queued media with attachmentID %s, queue length is %d", processingMedia.AttachmentID(), m.pool.Queue()) - logrus.Tracef("ProcessMedia: about to enqueue media with attachmentID %s, queue length is %d", media.AttachmentID(), m.pool.Queue()) - m.pool.Enqueue(func(innerCtx context.Context) { - select { - case <-innerCtx.Done(): - // if the inner context is done that means the worker pool is closing, so we should just return - return - default: - // start loading the media already for the caller's convenience - if _, err := media.Load(innerCtx); err != nil { - logrus.Errorf("ProcessMedia: error processing media with attachmentID %s: %s", media.AttachmentID(), err) - } - } - }) - logrus.Tracef("ProcessMedia: succesfully queued media with attachmentID %s, queue length is %d", media.AttachmentID(), m.pool.Queue()) + return processingMedia, nil +} - return media, nil - default: - return nil, fmt.Errorf("content type %s not (yet) supported", contentType) +func (m *manager) ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) { + processingEmoji, err := m.preProcessEmoji(ctx, data, shortcode, ai) + if err != nil { + return nil, err } -} -func (m *manager) ProcessEmoji(ctx context.Context, data []byte, accountID string) (*Processing, error) { - return nil, nil + logrus.Tracef("ProcessEmoji: about to enqueue emoji with id %s, queue length is %d", processingEmoji.EmojiID(), m.pool.Queue()) + m.pool.Enqueue(func(innerCtx context.Context) { + select { + case <-innerCtx.Done(): + // if the inner context is done that means the worker pool is closing, so we should just return + return + default: + // start loading the emoji already for the caller's convenience + if _, err := processingEmoji.LoadEmoji(innerCtx); err != nil { + logrus.Errorf("ProcessEmoji: error processing emoji with id %s: %s", processingEmoji.EmojiID(), err) + } + } + }) + logrus.Tracef("ProcessEmoji: succesfully queued emoji with id %s, queue length is %d", processingEmoji.EmojiID(), m.pool.Queue()) + + return processingEmoji, nil } func (m *manager) NumWorkers() int { diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 74d0c3008..0fadceb37 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -37,21 +37,21 @@ type ManagerTestSuite struct { func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { ctx := context.Background() - // load bytes from a test image - testBytes, err := os.ReadFile("./test/test-jpeg.jpg") - suite.NoError(err) - suite.NotEmpty(testBytes) + data := func(_ context.Context) ([]byte, error) { + // load bytes from a test image + return os.ReadFile("./test/test-jpeg.jpg") + } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil) suite.NoError(err) // fetch the attachment id from the processing media attachmentID := processingMedia.AttachmentID() // do a blocking call to fetch the attachment - attachment, err := processingMedia.Load(ctx) + attachment, err := processingMedia.LoadAttachment(ctx) suite.NoError(err) suite.NotNil(attachment) @@ -103,15 +103,15 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { ctx := context.Background() - // load bytes from a test image - testBytes, err := os.ReadFile("./test/test-jpeg.jpg") - suite.NoError(err) - suite.NotEmpty(testBytes) + data := func(_ context.Context) ([]byte, error) { + // load bytes from a test image + return os.ReadFile("./test/test-jpeg.jpg") + } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil) suite.NoError(err) // fetch the attachment id from the processing media attachmentID := processingMedia.AttachmentID() @@ -183,13 +183,17 @@ func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() { suite.NoError(err) suite.NotEmpty(testBytes) + data := func(_ context.Context) ([]byte, error) { + return testBytes, nil + } + accountID := "01FS1X72SK9ZPW0J1QQ68BD264" spam := 50 - inProcess := []*media.Processing{} + inProcess := []*media.ProcessingMedia{} for i := 0; i < spam; i++ { // process the media with no additional info provided - processingMedia, err := suite.manager.ProcessMedia(ctx, testBytes, accountID, nil) + processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil) suite.NoError(err) inProcess = append(inProcess, processingMedia) } @@ -201,7 +205,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() { attachmentID := processingMedia.AttachmentID() // do a blocking call to fetch the attachment - attachment, err := processingMedia.Load(ctx) + attachment, err := processingMedia.LoadAttachment(ctx) suite.NoError(err) suite.NotNil(attachment) diff --git a/internal/media/processing.go b/internal/media/processing.go deleted file mode 100644 index 3f9fc2bfc..000000000 --- a/internal/media/processing.go +++ /dev/null @@ -1,256 +0,0 @@ -/* - 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 media - -import ( - "context" - "fmt" - "sync" - "time" - - "codeberg.org/gruf/go-store/kv" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type processState int - -const ( - received processState = iota // processing order has been received but not done yet - complete // processing order has been completed successfully - errored // processing order has been completed with an error -) - -// Processing represents a piece of media that is currently being processed. It exposes -// various functions for retrieving data from the process. -type Processing struct { - mu sync.Mutex - - /* - below fields should be set on newly created media; - attachment will be updated incrementally as media goes through processing - */ - - attachment *gtsmodel.MediaAttachment // will only be set if the media is an attachment - emoji *gtsmodel.Emoji // will only be set if the media is an emoji - - rawData []byte - - /* - below fields represent the processing state of the media thumbnail - */ - - thumbstate processState - thumb *ImageMeta - - /* - below fields represent the processing state of the full-sized media - */ - - fullSizeState processState - fullSize *ImageMeta - - /* - below pointers to database and storage are maintained so that - the media can store and update itself during processing steps - */ - - database db.DB - storage *kv.KVStore - - err error // error created during processing, if any -} - -func (p *Processing) Thumb(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - - switch p.thumbstate { - case received: - // we haven't processed a thumbnail for this media yet so do it now - - // check if we need to create a blurhash or if there's already one set - var createBlurhash bool - if p.attachment.Blurhash == "" { - // no blurhash created yet - createBlurhash = true - } - - thumb, err := deriveThumbnail(p.rawData, p.attachment.File.ContentType, createBlurhash) - if err != nil { - p.err = fmt.Errorf("error deriving thumbnail: %s", err) - p.thumbstate = errored - return nil, p.err - } - - // put the thumbnail in storage - if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.image); err != nil { - p.err = fmt.Errorf("error storing thumbnail: %s", err) - p.thumbstate = errored - return nil, p.err - } - - // set appropriate fields on the attachment based on the thumbnail we derived - if createBlurhash { - p.attachment.Blurhash = thumb.blurhash - } - - p.attachment.FileMeta.Small = gtsmodel.Small{ - Width: thumb.width, - Height: thumb.height, - Size: thumb.size, - Aspect: thumb.aspect, - } - p.attachment.Thumbnail.FileSize = thumb.size - - if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { - p.err = err - p.thumbstate = errored - return nil, err - } - - // set the thumbnail of this media - p.thumb = thumb - - // we're done processing the thumbnail! - p.thumbstate = complete - fallthrough - case complete: - return p.thumb, nil - case errored: - return nil, p.err - } - - return nil, fmt.Errorf("thumbnail processing status %d unknown", p.thumbstate) -} - -func (p *Processing) FullSize(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - - switch p.fullSizeState { - case received: - var clean []byte - var err error - var decoded *ImageMeta - - ct := p.attachment.File.ContentType - switch ct { - case mimeImageJpeg, mimeImagePng: - // first 'clean' image by purging exif data from it - var exifErr error - if clean, exifErr = purgeExif(p.rawData); exifErr != nil { - err = exifErr - break - } - decoded, err = decodeImage(clean, ct) - case mimeImageGif: - // gifs are already clean - no exif data to remove - clean = p.rawData - decoded, err = decodeGif(clean) - default: - err = fmt.Errorf("content type %s not a processible image type", ct) - } - - if err != nil { - p.err = err - p.fullSizeState = errored - return nil, err - } - - // put the full size in storage - if err := p.storage.Put(p.attachment.File.Path, decoded.image); err != nil { - p.err = fmt.Errorf("error storing full size image: %s", err) - p.fullSizeState = errored - return nil, p.err - } - - // set appropriate fields on the attachment based on the image we derived - p.attachment.FileMeta.Original = gtsmodel.Original{ - Width: decoded.width, - Height: decoded.height, - Size: decoded.size, - Aspect: decoded.aspect, - } - p.attachment.File.FileSize = decoded.size - p.attachment.File.UpdatedAt = time.Now() - p.attachment.Processing = gtsmodel.ProcessingStatusProcessed - - if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { - p.err = err - p.fullSizeState = errored - return nil, err - } - - // set the fullsize of this media - p.fullSize = decoded - - // we're done processing the full-size image - p.fullSizeState = complete - fallthrough - case complete: - return p.fullSize, nil - case errored: - return nil, p.err - } - - return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) -} - -// AttachmentID returns the ID of the underlying media attachment without blocking processing. -func (p *Processing) AttachmentID() string { - return p.attachment.ID -} - -// Load blocks until the thumbnail and fullsize content has been processed, and then -// returns the completed attachment. -func (p *Processing) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - if _, err := p.Thumb(ctx); err != nil { - return nil, err - } - - if _, err := p.FullSize(ctx); err != nil { - return nil, err - } - - return p.attachment, nil -} - -func (p *Processing) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - return nil, nil -} - -func (p *Processing) Finished() bool { - return p.thumbstate == complete && p.fullSizeState == complete -} - -// putOrUpdateAttachment is just a convenience function for first trying to PUT the attachment in the database, -// and then if that doesn't work because the attachment already exists, updating it instead. -func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { - if err := database.Put(ctx, attachment); err != nil { - if err != db.ErrAlreadyExists { - return fmt.Errorf("putOrUpdateAttachment: proper error while putting attachment: %s", err) - } - if err := database.UpdateByPrimaryKey(ctx, attachment); err != nil { - return fmt.Errorf("putOrUpdateAttachment: error while updating attachment: %s", err) - } - } - - return nil -} diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go new file mode 100644 index 000000000..7e2d4f31f --- /dev/null +++ b/internal/media/processingemoji.go @@ -0,0 +1,382 @@ +/* + 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 media + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "codeberg.org/gruf/go-store/kv" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +// ProcessingEmoji represents an emoji currently processing. It exposes +// various functions for retrieving data from the process. +type ProcessingEmoji struct { + mu sync.Mutex + + /* + below fields should be set on newly created media; + emoji will be updated incrementally as media goes through processing + */ + + emoji *gtsmodel.Emoji + data DataFunc + + rawData []byte // will be set once the fetchRawData function has been called + + /* + below fields represent the processing state of the static version of the emoji + */ + + staticState processState + static *ImageMeta + + /* + below fields represent the processing state of the emoji image + */ + + fullSizeState processState + fullSize *ImageMeta + + /* + below pointers to database and storage are maintained so that + the media can store and update itself during processing steps + */ + + database db.DB + storage *kv.KVStore + + err error // error created during processing, if any +} + +// EmojiID returns the ID of the underlying emoji without blocking processing. +func (p *ProcessingEmoji) EmojiID() string { + return p.emoji.ID +} + +// LoadEmoji blocks until the static and fullsize image +// has been processed, and then returns the completed emoji. +func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { + if err := p.fetchRawData(ctx); err != nil { + return nil, err + } + + if _, err := p.loadStatic(ctx); err != nil { + return nil, err + } + + if _, err := p.loadFullSize(ctx); err != nil { + return nil, err + } + + return p.emoji, nil +} + +// Finished returns true if processing has finished for both the thumbnail +// and full fized version of this piece of media. +func (p *ProcessingEmoji) Finished() bool { + return p.staticState == complete && p.fullSizeState == complete +} + +func (p *ProcessingEmoji) loadStatic(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.staticState { + case received: + // we haven't processed a static version of this emoji yet so do it now + static, err := deriveStaticEmoji(p.rawData, p.emoji.ImageContentType) + if err != nil { + p.err = fmt.Errorf("error deriving static: %s", err) + p.staticState = errored + return nil, p.err + } + + // put the static in storage + if err := p.storage.Put(p.emoji.ImageStaticPath, static.image); err != nil { + p.err = fmt.Errorf("error storing static: %s", err) + p.staticState = errored + return nil, p.err + } + + // set appropriate fields on the emoji based on the static version we derived + p.attachment.FileMeta.Small = gtsmodel.Small{ + Width: static.width, + Height: static.height, + Size: static.size, + Aspect: static.aspect, + } + p.attachment.Thumbnail.FileSize = static.size + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.thumbstate = errored + return nil, err + } + + // set the thumbnail of this media + p.thumb = static + + // we're done processing the thumbnail! + p.thumbstate = complete + fallthrough + case complete: + return p.thumb, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("thumbnail processing status %d unknown", p.thumbstate) +} + +func (p *ProcessingEmoji) loadFullSize(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.fullSizeState { + case received: + var clean []byte + var err error + var decoded *ImageMeta + + ct := p.attachment.File.ContentType + switch ct { + case mimeImageJpeg, mimeImagePng: + // first 'clean' image by purging exif data from it + var exifErr error + if clean, exifErr = purgeExif(p.rawData); exifErr != nil { + err = exifErr + break + } + decoded, err = decodeImage(clean, ct) + case mimeImageGif: + // gifs are already clean - no exif data to remove + clean = p.rawData + decoded, err = decodeGif(clean) + default: + err = fmt.Errorf("content type %s not a processible image type", ct) + } + + if err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // put the full size in storage + if err := p.storage.Put(p.attachment.File.Path, decoded.image); err != nil { + p.err = fmt.Errorf("error storing full size image: %s", err) + p.fullSizeState = errored + return nil, p.err + } + + // set appropriate fields on the attachment based on the image we derived + p.attachment.FileMeta.Original = gtsmodel.Original{ + Width: decoded.width, + Height: decoded.height, + Size: decoded.size, + Aspect: decoded.aspect, + } + p.attachment.File.FileSize = decoded.size + p.attachment.File.UpdatedAt = time.Now() + p.attachment.Processing = gtsmodel.ProcessingStatusProcessed + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // set the fullsize of this media + p.fullSize = decoded + + // we're done processing the full-size image + p.fullSizeState = complete + fallthrough + case complete: + return p.fullSize, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) +} + +// fetchRawData calls the data function attached to p if it hasn't been called yet, +// and updates the underlying attachment fields as necessary. +// It should only be called from within a function that already has a lock on p! +func (p *ProcessingEmoji) fetchRawData(ctx context.Context) error { + // check if we've already done this and bail early if we have + if p.rawData != nil { + return nil + } + + // execute the data function and pin the raw bytes for further processing + b, err := p.data(ctx) + if err != nil { + return fmt.Errorf("fetchRawData: error executing data function: %s", err) + } + p.rawData = b + + // now we have the data we can work out the content type + contentType, err := parseContentType(p.rawData) + if err != nil { + return fmt.Errorf("fetchRawData: error parsing content type: %s", err) + } + + if !supportedEmoji(contentType) { + return fmt.Errorf("fetchRawData: content type %s was not valid for an emoji", contentType) + } + + split := strings.Split(contentType, "/") + mainType := split[0] // something like 'image' + extension := split[1] // something like 'gif' + + // set some additional fields on the emoji now that + // we know more about what the underlying image actually is + p.emoji.ImageURL = uris.GenerateURIForAttachment(p.attachment.AccountID, string(TypeAttachment), string(SizeOriginal), p.attachment.ID, extension) + p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension) + p.attachment.File.ContentType = contentType + + switch mainType { + case mimeImage: + if extension == mimeGif { + p.attachment.Type = gtsmodel.FileTypeGif + } else { + p.attachment.Type = gtsmodel.FileTypeImage + } + default: + return fmt.Errorf("fetchRawData: cannot process mime type %s (yet)", mainType) + } + + return nil +} + +// putOrUpdateEmoji is just a convenience function for first trying to PUT the emoji in the database, +// and then if that doesn't work because the emoji already exists, updating it instead. +func putOrUpdateEmoji(ctx context.Context, database db.DB, emoji *gtsmodel.Emoji) error { + if err := database.Put(ctx, emoji); err != nil { + if err != db.ErrAlreadyExists { + return fmt.Errorf("putOrUpdateEmoji: proper error while putting emoji: %s", err) + } + if err := database.UpdateByPrimaryKey(ctx, emoji); err != nil { + return fmt.Errorf("putOrUpdateEmoji: error while updating emoji: %s", err) + } + } + + return nil +} + +func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, shortcode string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) { + instanceAccount, err := m.db.GetInstanceAccount(ctx, "") + if err != nil { + return nil, fmt.Errorf("preProcessEmoji: error fetching this instance account from the db: %s", err) + } + + id, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + // populate initial fields on the emoji -- some of these will be overwritten as we proceed + emoji := >smodel.Emoji{ + ID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Shortcode: shortcode, + Domain: "", // assume our own domain unless told otherwise + ImageRemoteURL: "", + ImageStaticRemoteURL: "", + ImageURL: "", // we don't know yet + ImageStaticURL: uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), id, mimePng), // all static emojis are encoded as png + ImagePath: "", // we don't know yet + ImageStaticPath: fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeStatic, id, mimePng), // all static emojis are encoded as png + ImageContentType: "", // we don't know yet + ImageStaticContentType: mimeImagePng, // all static emojis are encoded as png + ImageFileSize: 0, + ImageStaticFileSize: 0, + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: "", // we don't know yet + VisibleInPicker: true, + CategoryID: "", + } + + // check if we have additional info to add to the emoji, + // and overwrite some of the emoji fields if so + if ai != nil { + if ai.CreatedAt != nil { + attachment.CreatedAt = *ai.CreatedAt + } + + if ai.StatusID != nil { + attachment.StatusID = *ai.StatusID + } + + if ai.RemoteURL != nil { + attachment.RemoteURL = *ai.RemoteURL + } + + if ai.Description != nil { + attachment.Description = *ai.Description + } + + if ai.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *ai.ScheduledStatusID + } + + if ai.Blurhash != nil { + attachment.Blurhash = *ai.Blurhash + } + + if ai.Avatar != nil { + attachment.Avatar = *ai.Avatar + } + + if ai.Header != nil { + attachment.Header = *ai.Header + } + + if ai.FocusX != nil { + attachment.FileMeta.Focus.X = *ai.FocusX + } + + if ai.FocusY != nil { + attachment.FileMeta.Focus.Y = *ai.FocusY + } + } + + processingEmoji := &ProcessingEmoji{ + emoji: emoji, + data: data, + staticState: received, + fullSizeState: received, + database: m.db, + storage: m.storage, + } + + return processingEmoji, nil +} diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go new file mode 100644 index 000000000..a6e45034f --- /dev/null +++ b/internal/media/processingmedia.go @@ -0,0 +1,411 @@ +/* + 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 media + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "codeberg.org/gruf/go-store/kv" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +type processState int + +const ( + received processState = iota // processing order has been received but not done yet + complete // processing order has been completed successfully + errored // processing order has been completed with an error +) + +// ProcessingMedia represents a piece of media that is currently being processed. It exposes +// various functions for retrieving data from the process. +type ProcessingMedia struct { + mu sync.Mutex + + /* + below fields should be set on newly created media; + attachment will be updated incrementally as media goes through processing + */ + + attachment *gtsmodel.MediaAttachment + data DataFunc + + rawData []byte // will be set once the fetchRawData function has been called + + /* + below fields represent the processing state of the media thumbnail + */ + + thumbstate processState + thumb *ImageMeta + + /* + below fields represent the processing state of the full-sized media + */ + + fullSizeState processState + fullSize *ImageMeta + + /* + below pointers to database and storage are maintained so that + the media can store and update itself during processing steps + */ + + database db.DB + storage *kv.KVStore + + err error // error created during processing, if any +} + +// AttachmentID returns the ID of the underlying media attachment without blocking processing. +func (p *ProcessingMedia) AttachmentID() string { + return p.attachment.ID +} + +// LoadAttachment blocks until the thumbnail and fullsize content +// has been processed, and then returns the completed attachment. +func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { + if err := p.fetchRawData(ctx); err != nil { + return nil, err + } + + if _, err := p.loadThumb(ctx); err != nil { + return nil, err + } + + if _, err := p.loadFullSize(ctx); err != nil { + return nil, err + } + + return p.attachment, nil +} + +func (p *ProcessingMedia) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { + return nil, nil +} + +// Finished returns true if processing has finished for both the thumbnail +// and full fized version of this piece of media. +func (p *ProcessingMedia) Finished() bool { + return p.thumbstate == complete && p.fullSizeState == complete +} + +func (p *ProcessingMedia) loadThumb(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.thumbstate { + case received: + // we haven't processed a thumbnail for this media yet so do it now + + // check if we need to create a blurhash or if there's already one set + var createBlurhash bool + if p.attachment.Blurhash == "" { + // no blurhash created yet + createBlurhash = true + } + + thumb, err := deriveThumbnail(p.rawData, p.attachment.File.ContentType, createBlurhash) + if err != nil { + p.err = fmt.Errorf("error deriving thumbnail: %s", err) + p.thumbstate = errored + return nil, p.err + } + + // put the thumbnail in storage + if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.image); err != nil { + p.err = fmt.Errorf("error storing thumbnail: %s", err) + p.thumbstate = errored + return nil, p.err + } + + // set appropriate fields on the attachment based on the thumbnail we derived + if createBlurhash { + p.attachment.Blurhash = thumb.blurhash + } + + p.attachment.FileMeta.Small = gtsmodel.Small{ + Width: thumb.width, + Height: thumb.height, + Size: thumb.size, + Aspect: thumb.aspect, + } + p.attachment.Thumbnail.FileSize = thumb.size + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.thumbstate = errored + return nil, err + } + + // set the thumbnail of this media + p.thumb = thumb + + // we're done processing the thumbnail! + p.thumbstate = complete + fallthrough + case complete: + return p.thumb, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("thumbnail processing status %d unknown", p.thumbstate) +} + +func (p *ProcessingMedia) loadFullSize(ctx context.Context) (*ImageMeta, error) { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.fullSizeState { + case received: + var clean []byte + var err error + var decoded *ImageMeta + + ct := p.attachment.File.ContentType + switch ct { + case mimeImageJpeg, mimeImagePng: + // first 'clean' image by purging exif data from it + var exifErr error + if clean, exifErr = purgeExif(p.rawData); exifErr != nil { + err = exifErr + break + } + decoded, err = decodeImage(clean, ct) + case mimeImageGif: + // gifs are already clean - no exif data to remove + clean = p.rawData + decoded, err = decodeGif(clean) + default: + err = fmt.Errorf("content type %s not a processible image type", ct) + } + + if err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // put the full size in storage + if err := p.storage.Put(p.attachment.File.Path, decoded.image); err != nil { + p.err = fmt.Errorf("error storing full size image: %s", err) + p.fullSizeState = errored + return nil, p.err + } + + // set appropriate fields on the attachment based on the image we derived + p.attachment.FileMeta.Original = gtsmodel.Original{ + Width: decoded.width, + Height: decoded.height, + Size: decoded.size, + Aspect: decoded.aspect, + } + p.attachment.File.FileSize = decoded.size + p.attachment.File.UpdatedAt = time.Now() + p.attachment.Processing = gtsmodel.ProcessingStatusProcessed + + if err := putOrUpdateAttachment(ctx, p.database, p.attachment); err != nil { + p.err = err + p.fullSizeState = errored + return nil, err + } + + // set the fullsize of this media + p.fullSize = decoded + + // we're done processing the full-size image + p.fullSizeState = complete + fallthrough + case complete: + return p.fullSize, nil + case errored: + return nil, p.err + } + + return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) +} + +// fetchRawData calls the data function attached to p if it hasn't been called yet, +// and updates the underlying attachment fields as necessary. +// It should only be called from within a function that already has a lock on p! +func (p *ProcessingMedia) fetchRawData(ctx context.Context) error { + // check if we've already done this and bail early if we have + if p.rawData != nil { + return nil + } + + // execute the data function and pin the raw bytes for further processing + b, err := p.data(ctx) + if err != nil { + return fmt.Errorf("fetchRawData: error executing data function: %s", err) + } + p.rawData = b + + // now we have the data we can work out the content type + contentType, err := parseContentType(p.rawData) + if err != nil { + return fmt.Errorf("fetchRawData: error parsing content type: %s", err) + } + + split := strings.Split(contentType, "/") + if len(split) != 2 { + return fmt.Errorf("fetchRawData: content type %s was not valid", contentType) + } + + mainType := split[0] // something like 'image' + extension := split[1] // something like 'jpeg' + + // set some additional fields on the attachment now that + // we know more about what the underlying media actually is + p.attachment.URL = uris.GenerateURIForAttachment(p.attachment.AccountID, string(TypeAttachment), string(SizeOriginal), p.attachment.ID, extension) + p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension) + p.attachment.File.ContentType = contentType + + switch mainType { + case mimeImage: + if extension == mimeGif { + p.attachment.Type = gtsmodel.FileTypeGif + } else { + p.attachment.Type = gtsmodel.FileTypeImage + } + default: + return fmt.Errorf("fetchRawData: cannot process mime type %s (yet)", mainType) + } + + return nil +} + +// putOrUpdateAttachment is just a convenience function for first trying to PUT the attachment in the database, +// and then if that doesn't work because the attachment already exists, updating it instead. +func putOrUpdateAttachment(ctx context.Context, database db.DB, attachment *gtsmodel.MediaAttachment) error { + if err := database.Put(ctx, attachment); err != nil { + if err != db.ErrAlreadyExists { + return fmt.Errorf("putOrUpdateAttachment: proper error while putting attachment: %s", err) + } + if err := database.UpdateByPrimaryKey(ctx, attachment); err != nil { + return fmt.Errorf("putOrUpdateAttachment: error while updating attachment: %s", err) + } + } + + return nil +} + +func (m *manager) preProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) { + id, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + file := gtsmodel.File{ + Path: "", // we don't know yet because it depends on the uncalled DataFunc + ContentType: "", // we don't know yet because it depends on the uncalled DataFunc + UpdatedAt: time.Now(), + } + + thumbnail := gtsmodel.Thumbnail{ + URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, + Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, + ContentType: mimeJpeg, + UpdatedAt: time.Now(), + } + + // populate initial fields on the media attachment -- some of these will be overwritten as we proceed + attachment := >smodel.MediaAttachment{ + ID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StatusID: "", + URL: "", // we don't know yet because it depends on the uncalled DataFunc + RemoteURL: "", + Type: gtsmodel.FileTypeUnknown, // we don't know yet because it depends on the uncalled DataFunc + FileMeta: gtsmodel.FileMeta{}, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: "", + Processing: gtsmodel.ProcessingStatusReceived, + File: file, + Thumbnail: thumbnail, + Avatar: false, + Header: false, + } + + // check if we have additional info to add to the attachment, + // and overwrite some of the attachment fields if so + if ai != nil { + if ai.CreatedAt != nil { + attachment.CreatedAt = *ai.CreatedAt + } + + if ai.StatusID != nil { + attachment.StatusID = *ai.StatusID + } + + if ai.RemoteURL != nil { + attachment.RemoteURL = *ai.RemoteURL + } + + if ai.Description != nil { + attachment.Description = *ai.Description + } + + if ai.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *ai.ScheduledStatusID + } + + if ai.Blurhash != nil { + attachment.Blurhash = *ai.Blurhash + } + + if ai.Avatar != nil { + attachment.Avatar = *ai.Avatar + } + + if ai.Header != nil { + attachment.Header = *ai.Header + } + + if ai.FocusX != nil { + attachment.FileMeta.Focus.X = *ai.FocusX + } + + if ai.FocusY != nil { + attachment.FileMeta.Focus.Y = *ai.FocusY + } + } + + processingMedia := &ProcessingMedia{ + attachment: attachment, + data: data, + thumbstate: received, + fullSizeState: received, + database: m.db, + storage: m.storage, + } + + return processingMedia, nil +} diff --git a/internal/media/types.go b/internal/media/types.go index aaf423682..6426223d1 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -20,6 +20,7 @@ package media import ( "bytes" + "context" "errors" "fmt" "time" @@ -68,9 +69,9 @@ const ( TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests ) -// AdditionalInfo represents additional information that should be added to an attachment +// AdditionalMediaInfo represents additional information that should be added to an attachment // when processing a piece of media. -type AdditionalInfo struct { +type AdditionalMediaInfo struct { // Time that this media was created; defaults to time.Now(). CreatedAt *time.Time // ID of the status to which this media is attached; defaults to "". @@ -93,6 +94,13 @@ type AdditionalInfo struct { FocusY *float32 } +type AdditionalEmojiInfo struct { + +} + +// DataFunc represents a function used to retrieve the raw bytes of a piece of media. +type DataFunc func(ctx context.Context) ([]byte, error) + // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). // Returns an error if the content type is not something we can process. func parseContentType(content []byte) (string, error) { diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 6d15b5afb..7b305dc95 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -137,84 +137,87 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form // parsing and checking the image, and doing the necessary updates in the database for this to become // the account's new avatar image. func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error maxImageSize := viper.GetInt(config.Keys.MediaImageMaxSize) if int(avatar.Size) > maxImageSize { - err = fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) - return nil, err - } - f, err := avatar.Open() - if err != nil { - return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) + return nil, fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) } - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) - } - if size == 0 { - return nil, errors.New("UpdateAvatar: could not read provided avatar: size 0 bytes") - } + dataFunc := func(ctx context.Context) ([]byte, error) { + // pop open the fileheader + f, err := avatar.Open() + if err != nil { + return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) + } - // we're done with the FileHeader now - if err := f.Close(); err != nil { - return nil, fmt.Errorf("UpdateAvatar: error closing multipart fileheader: %s", err) + // extract the bytes + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) + } + if size == 0 { + return nil, errors.New("UpdateAvatar: could not read provided avatar: size 0 bytes") + } + + return buf.Bytes(), f.Close() } - // do the setting isAvatar := true - processingMedia, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, &media.AdditionalInfo{ + ai := &media.AdditionalMediaInfo{ Avatar: &isAvatar, - }) + } + + processingMedia, err := p.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai) if err != nil { return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err) } - return processingMedia.Load(ctx) + return processingMedia.LoadAttachment(ctx) } // UpdateHeader does the dirty work of checking the header part of an account update form, // parsing and checking the image, and doing the necessary updates in the database for this to become // the account's new header image. func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error maxImageSize := viper.GetInt(config.Keys.MediaImageMaxSize) if int(header.Size) > maxImageSize { - err = fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) - return nil, err - } - f, err := header.Open() - if err != nil { - return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) + return nil, fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) } - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) - } - if size == 0 { - return nil, errors.New("UpdateHeader: could not read provided header: size 0 bytes") - } + dataFunc := func(ctx context.Context) ([]byte, error) { + // pop open the fileheader + f, err := header.Open() + if err != nil { + return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) + } + + // extract the bytes + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) + } + if size == 0 { + return nil, errors.New("UpdateHeader: could not read provided header: size 0 bytes") + } - // we're done with the FileHeader now - if err := f.Close(); err != nil { - return nil, fmt.Errorf("UpdateHeader: error closing multipart fileheader: %s", err) + return buf.Bytes(), f.Close() } - // do the setting isHeader := true - processingMedia, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), accountID, &media.AdditionalInfo{ + ai := &media.AdditionalMediaInfo{ Header: &isHeader, - }) + } + + processingMedia, err := p.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai) + if err != nil { + return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err) + } if err != nil { return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err) } - return processingMedia.Load(ctx) + return processingMedia.LoadAttachment(ctx) } func (p *processor) processNote(ctx context.Context, note string, accountID string) (string, error) { diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index 737a4ebe2..8858dbd02 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -30,30 +30,34 @@ import ( ) func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { - if user.Admin { + if !user.Admin { return nil, fmt.Errorf("user %s not an admin", user.ID) } - // open the emoji and extract the bytes from it - f, err := form.Image.Open() - if err != nil { - return nil, fmt.Errorf("error opening emoji: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading emoji: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided emoji: size 0 bytes") + data := func(innerCtx context.Context) ([]byte, error) { + // open the emoji and extract the bytes from it + f, err := form.Image.Open() + if err != nil { + return nil, fmt.Errorf("error opening emoji: %s", err) + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("error reading emoji: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided emoji: size 0 bytes") + } + + return buf.Bytes(), f.Close() } - media, err := p.mediaManager.ProcessEmoji(ctx, buf.Bytes(), account.ID) + processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, form.Shortcode, nil) if err != nil { return nil, err } - emoji, err := media.LoadEmoji(ctx) + emoji, err := processingEmoji.LoadEmoji(ctx) if err != nil { return nil, err } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 9df5c7c1f..0896315b1 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -31,18 +31,21 @@ import ( ) func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { - // open the attachment and extract the bytes from it - f, err := form.File.Open() - if err != nil { - return nil, fmt.Errorf("error opening attachment: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading attachment: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided attachment: size 0 bytes") + data := func(innerCtx context.Context) ([]byte, error) { + // open the attachment and extract the bytes from it + f, err := form.File.Open() + if err != nil { + return nil, fmt.Errorf("error opening attachment: %s", err) + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("error reading attachment: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided attachment: size 0 bytes") + } + return buf.Bytes(), f.Close() } focusX, focusY, err := parseFocus(form.Focus) @@ -51,7 +54,7 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form } // process the media attachment and load it immediately - media, err := p.mediaManager.ProcessMedia(ctx, buf.Bytes(), account.ID, &media.AdditionalInfo{ + media, err := p.mediaManager.ProcessMedia(ctx, data, account.ID, &media.AdditionalMediaInfo{ Description: &form.Description, FocusX: &focusX, FocusY: &focusY, @@ -60,7 +63,7 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, err } - attachment, err := media.Load(ctx) + attachment, err := media.LoadAttachment(ctx) if err != nil { return nil, err } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index b470b289a..c43515a42 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -33,7 +33,7 @@ import ( // functionality for fetching remote media. type Transport interface { pub.Transport - // DereferenceMedia fetches the bytes of the given media attachment IRI, with the expectedContentType. + // DereferenceMedia fetches the bytes of the given media attachment IRI. DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, error) // DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo. DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error) diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 203aaef96..9a9ea5d2f 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -66,6 +66,16 @@ func NewTestTokens() map[string]*gtsmodel.Token { AccessCreateAt: time.Now(), AccessExpiresAt: time.Now().Add(72 * time.Hour), }, + "admin_account": { + ID: "01FS4TP8ANA5VE92EAPA9E0M7Q", + ClientID: "01F8MGWSJCND9BWBD4WGJXBM93", + UserID: "01F8MGWYWKVKS3VS8DV1AMYPGE", + RedirectURI: "http://localhost:8080", + Scope: "read write follow push admin", + Access: "AININALKNENFNF98717NAMG4LWE4NJITMWUXM2M4MTRHZDEX", + AccessCreateAt: time.Now(), + AccessExpiresAt: time.Now().Add(72 * time.Hour), + }, } return tokens } -- cgit v1.2.3 From 589bb9df0275457b5f9c3790e67517ec1be1745d Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Sun, 16 Jan 2022 18:52:55 +0100 Subject: pass reader around instead of []byte --- internal/federation/dereferencing/account.go | 5 +- internal/federation/dereferencing/media.go | 3 +- internal/media/image.go | 46 ++++--- internal/media/manager_test.go | 32 +++-- internal/media/processingemoji.go | 168 +++++++++++-------------- internal/media/processingmedia.go | 179 ++++++++++++++------------- internal/media/types.go | 5 +- internal/media/util.go | 11 +- internal/processing/account/update.go | 42 +------ internal/processing/admin/emoji.go | 20 +-- internal/processing/media/create.go | 19 +-- internal/transport/derefmedia.go | 7 +- internal/transport/transport.go | 5 +- testrig/storage.go | 82 +----------- 14 files changed, 238 insertions(+), 386 deletions(-) (limited to 'internal/processing/media/create.go') diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index b9efbfa45..6ea8256d5 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/url" "strings" @@ -251,7 +252,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data := func(innerCtx context.Context) ([]byte, error) { + data := func(innerCtx context.Context) (io.Reader, error) { return t.DereferenceMedia(innerCtx, avatarIRI) } @@ -273,7 +274,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data := func(innerCtx context.Context) ([]byte, error) { + data := func(innerCtx context.Context) (io.Reader, error) { return t.DereferenceMedia(innerCtx, headerIRI) } diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index 46cb4a5f4..c427f2507 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -21,6 +21,7 @@ package dereferencing import ( "context" "fmt" + "io" "net/url" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -41,7 +42,7 @@ func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, a return nil, fmt.Errorf("GetRemoteMedia: error parsing url: %s", err) } - dataFunc := func(innerCtx context.Context) ([]byte, error) { + dataFunc := func(innerCtx context.Context) (io.Reader, error) { return t.DereferenceMedia(innerCtx, derefURI) } diff --git a/internal/media/image.go b/internal/media/image.go index de4b71210..b8f00024f 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -26,6 +26,7 @@ import ( "image/gif" "image/jpeg" "image/png" + "io" "github.com/buckket/go-blurhash" "github.com/nfnt/resize" @@ -37,17 +38,17 @@ const ( thumbnailMaxHeight = 512 ) -type ImageMeta struct { - image []byte +type imageMeta struct { width int height int size int aspect float64 - blurhash string + blurhash string // defined only for calls to deriveThumbnail if createBlurhash is true + small []byte // defined only for calls to deriveStaticEmoji or deriveThumbnail } -func decodeGif(b []byte) (*ImageMeta, error) { - gif, err := gif.DecodeAll(bytes.NewReader(b)) +func decodeGif(r io.Reader) (*imageMeta, error) { + gif, err := gif.DecodeAll(r) if err != nil { return nil, err } @@ -58,8 +59,7 @@ func decodeGif(b []byte) (*ImageMeta, error) { size := width * height aspect := float64(width) / float64(height) - return &ImageMeta{ - image: b, + return &imageMeta{ width: width, height: height, size: size, @@ -67,15 +67,15 @@ func decodeGif(b []byte) (*ImageMeta, error) { }, nil } -func decodeImage(b []byte, contentType string) (*ImageMeta, error) { +func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { var i image.Image var err error switch contentType { case mimeImageJpeg: - i, err = jpeg.Decode(bytes.NewReader(b)) + i, err = jpeg.Decode(r) case mimeImagePng: - i, err = png.Decode(bytes.NewReader(b)) + i, err = png.Decode(r) default: err = fmt.Errorf("content type %s not recognised", contentType) } @@ -93,8 +93,7 @@ func decodeImage(b []byte, contentType string) (*ImageMeta, error) { size := width * height aspect := float64(width) / float64(height) - return &ImageMeta{ - image: b, + return &imageMeta{ width: width, height: height, size: size, @@ -111,17 +110,17 @@ func decodeImage(b []byte, contentType string) (*ImageMeta, error) { // // If createBlurhash is false, then the blurhash field on the returned ImageAndMeta // will be an empty string. -func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageMeta, error) { +func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*imageMeta, error) { var i image.Image var err error switch contentType { case mimeImageJpeg: - i, err = jpeg.Decode(bytes.NewReader(b)) + i, err = jpeg.Decode(r) case mimeImagePng: - i, err = png.Decode(bytes.NewReader(b)) + i, err = png.Decode(r) case mimeImageGif: - i, err = gif.Decode(bytes.NewReader(b)) + i, err = gif.Decode(r) default: err = fmt.Errorf("content type %s can't be thumbnailed", contentType) } @@ -140,7 +139,7 @@ func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageM size := width * height aspect := float64(width) / float64(height) - im := &ImageMeta{ + im := &imageMeta{ width: width, height: height, size: size, @@ -165,25 +164,24 @@ func deriveThumbnail(b []byte, contentType string, createBlurhash bool) (*ImageM }); err != nil { return nil, err } - - im.image = out.Bytes() + im.small = out.Bytes() return im, nil } // deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. -func deriveStaticEmoji(b []byte, contentType string) (*ImageMeta, error) { +func deriveStaticEmoji(r io.Reader, contentType string) (*imageMeta, error) { var i image.Image var err error switch contentType { case mimeImagePng: - i, err = png.Decode(bytes.NewReader(b)) + i, err = png.Decode(r) if err != nil { return nil, err } case mimeImageGif: - i, err = gif.Decode(bytes.NewReader(b)) + i, err = gif.Decode(r) if err != nil { return nil, err } @@ -195,8 +193,8 @@ func deriveStaticEmoji(b []byte, contentType string) (*ImageMeta, error) { if err := png.Encode(out, i); err != nil { return nil, err } - return &ImageMeta{ - image: out.Bytes(), + return &imageMeta{ + small: out.Bytes(), }, nil } diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 0fadceb37..5380b83b1 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -19,8 +19,10 @@ package media_test import ( + "bytes" "context" "fmt" + "io" "os" "testing" "time" @@ -37,9 +39,13 @@ type ManagerTestSuite struct { func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { ctx := context.Background() - data := func(_ context.Context) ([]byte, error) { + data := func(_ context.Context) (io.Reader, error) { // load bytes from a test image - return os.ReadFile("./test/test-jpeg.jpg") + b, err := os.ReadFile("./test/test-jpeg.jpg") + if err != nil { + panic(err) + } + return bytes.NewBuffer(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" @@ -103,9 +109,13 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { ctx := context.Background() - data := func(_ context.Context) ([]byte, error) { + data := func(_ context.Context) (io.Reader, error) { // load bytes from a test image - return os.ReadFile("./test/test-jpeg.jpg") + b, err := os.ReadFile("./test/test-jpeg.jpg") + if err != nil { + panic(err) + } + return bytes.NewBuffer(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" @@ -175,16 +185,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() { // in this test, we spam the manager queue with 50 new media requests, just to see how it holds up - ctx := context.Background() - // load bytes from a test image - testBytes, err := os.ReadFile("./test/test-jpeg.jpg") - suite.NoError(err) - suite.NotEmpty(testBytes) + b, err := os.ReadFile("./test/test-jpeg.jpg") + if err != nil { + panic(err) + } - data := func(_ context.Context) ([]byte, error) { - return testBytes, nil + data := func(_ context.Context) (io.Reader, error) { + // load bytes from a test image + return bytes.NewReader(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index 467b500fc..147b6b5b3 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -19,8 +19,10 @@ package media import ( + "bytes" "context" "fmt" + "io" "strings" "sync" "time" @@ -46,22 +48,14 @@ type ProcessingEmoji struct { emoji *gtsmodel.Emoji data DataFunc - - rawData []byte // will be set once the fetchRawData function has been called + read bool // bool indicating that data function has been triggered already /* - below fields represent the processing state of the static version of the emoji - */ - - staticState processState - static *ImageMeta - - /* - below fields represent the processing state of the emoji image + below fields represent the processing state of the static of the emoji */ + staticState processState fullSizeState processState - fullSize *ImageMeta /* below pointers to database and storage are maintained so that @@ -85,21 +79,18 @@ func (p *ProcessingEmoji) EmojiID() string { // LoadEmoji blocks until the static and fullsize image // has been processed, and then returns the completed emoji. func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - if err := p.fetchRawData(ctx); err != nil { - return nil, err - } + p.mu.Lock() + defer p.mu.Unlock() - if _, err := p.loadStatic(ctx); err != nil { + if err := p.store(ctx); err != nil { return nil, err } - if _, err := p.loadFullSize(ctx); err != nil { + if err := p.loadStatic(ctx); err != nil { return nil, err } // store the result in the database before returning it - p.mu.Lock() - defer p.mu.Unlock() if !p.insertedInDB { if err := p.database.Put(ctx, p.emoji); err != nil { return nil, err @@ -116,118 +107,85 @@ func (p *ProcessingEmoji) Finished() bool { return p.staticState == complete && p.fullSizeState == complete } -func (p *ProcessingEmoji) loadStatic(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - +func (p *ProcessingEmoji) loadStatic(ctx context.Context) error { switch p.staticState { case received: - // we haven't processed a static version of this emoji yet so do it now - static, err := deriveStaticEmoji(p.rawData, p.emoji.ImageContentType) + // stream the original file out of storage... + stored, err := p.storage.GetStream(p.emoji.ImagePath) if err != nil { - p.err = fmt.Errorf("error deriving static: %s", err) + p.err = fmt.Errorf("loadStatic: error fetching file from storage: %s", err) p.staticState = errored - return nil, p.err + return p.err } - // put the static in storage - if err := p.storage.Put(p.emoji.ImageStaticPath, static.image); err != nil { - p.err = fmt.Errorf("error storing static: %s", err) + // we haven't processed a static version of this emoji yet so do it now + static, err := deriveStaticEmoji(stored, p.emoji.ImageContentType) + if err != nil { + p.err = fmt.Errorf("loadStatic: error deriving static: %s", err) p.staticState = errored - return nil, p.err + return p.err } - // set appropriate fields on the emoji based on the static version we derived - p.emoji.ImageStaticFileSize = len(static.image) - - // set the static on the processing emoji - p.static = static - - // we're done processing the static version of the emoji! - p.staticState = complete - fallthrough - case complete: - return p.static, nil - case errored: - return nil, p.err - } - - return nil, fmt.Errorf("static processing status %d unknown", p.staticState) -} - -func (p *ProcessingEmoji) loadFullSize(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - - switch p.fullSizeState { - case received: - var err error - var decoded *ImageMeta - - ct := p.emoji.ImageContentType - switch ct { - case mimeImagePng: - decoded, err = decodeImage(p.rawData, ct) - case mimeImageGif: - decoded, err = decodeGif(p.rawData) - default: - err = fmt.Errorf("content type %s not a processible emoji type", ct) - } - - if err != nil { - p.err = err - p.fullSizeState = errored - return nil, err + if err := stored.Close(); err != nil { + p.err = fmt.Errorf("loadStatic: error closing stored full size: %s", err) + p.staticState = errored + return p.err } - // put the full size emoji in storage - if err := p.storage.Put(p.emoji.ImagePath, decoded.image); err != nil { - p.err = fmt.Errorf("error storing full size emoji: %s", err) - p.fullSizeState = errored - return nil, p.err + // put the static in storage + if err := p.storage.Put(p.emoji.ImageStaticPath, static.small); err != nil { + p.err = fmt.Errorf("loadStatic: error storing static: %s", err) + p.staticState = errored + return p.err } - // set the fullsize of this media - p.fullSize = decoded + p.emoji.ImageStaticFileSize = len(static.small) - // we're done processing the full-size emoji - p.fullSizeState = complete + // we're done processing the static version of the emoji! + p.staticState = complete fallthrough case complete: - return p.fullSize, nil + return nil case errored: - return nil, p.err + return p.err } - return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) + return fmt.Errorf("static processing status %d unknown", p.staticState) } -// fetchRawData calls the data function attached to p if it hasn't been called yet, -// and updates the underlying emoji fields as necessary. -// It should only be called from within a function that already has a lock on p! -func (p *ProcessingEmoji) fetchRawData(ctx context.Context) error { +// store calls the data function attached to p if it hasn't been called yet, +// and updates the underlying attachment fields as necessary. It will then stream +// bytes from p's reader directly into storage so that it can be retrieved later. +func (p *ProcessingEmoji) store(ctx context.Context) error { // check if we've already done this and bail early if we have - if p.rawData != nil { + if p.read { return nil } - // execute the data function and pin the raw bytes for further processing - b, err := p.data(ctx) + // execute the data function to get the reader out of it + reader, err := p.data(ctx) if err != nil { - return fmt.Errorf("fetchRawData: error executing data function: %s", err) + return fmt.Errorf("store: error executing data function: %s", err) + } + + // extract no more than 261 bytes from the beginning of the file -- this is the header + firstBytes := make([]byte, maxFileHeaderBytes) + if _, err := reader.Read(firstBytes); err != nil { + return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err) } - p.rawData = b - // now we have the data we can work out the content type - contentType, err := parseContentType(p.rawData) + // now we have the file header we can work out the content type from it + contentType, err := parseContentType(firstBytes) if err != nil { - return fmt.Errorf("fetchRawData: error parsing content type: %s", err) + return fmt.Errorf("store: error parsing content type: %s", err) } + // bail if this is a type we can't process if !supportedEmoji(contentType) { - return fmt.Errorf("fetchRawData: content type %s was not valid for an emoji", contentType) + return fmt.Errorf("store: content type %s was not valid for an emoji", contentType) } + // extract the file extension split := strings.Split(contentType, "/") extension := split[1] // something like 'gif' @@ -236,8 +194,24 @@ func (p *ProcessingEmoji) fetchRawData(ctx context.Context) error { p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), p.emoji.ID, extension) p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, p.emoji.ID, extension) p.emoji.ImageContentType = contentType - p.emoji.ImageFileSize = len(p.rawData) + // concatenate the first bytes with the existing bytes still in the reader (thanks Mara) + multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader) + + // store this for now -- other processes can pull it out of storage as they please + if err := p.storage.PutStream(p.emoji.ImagePath, multiReader); err != nil { + return fmt.Errorf("store: error storing stream: %s", err) + } + p.emoji.ImageFileSize = 36702 // TODO: set this based on the result of PutStream + + // if the original reader is a readcloser, close it since we're done with it now + if rc, ok := reader.(io.ReadCloser); ok { + if err := rc.Close(); err != nil { + return fmt.Errorf("store: error closing readcloser: %s", err) + } + } + + p.read = true return nil } diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index 082c58607..82db863e0 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -19,8 +19,10 @@ package media import ( + "bytes" "context" "fmt" + "io" "strings" "sync" "time" @@ -44,22 +46,10 @@ type ProcessingMedia struct { attachment *gtsmodel.MediaAttachment data DataFunc + read bool // bool indicating that data function has been triggered already - rawData []byte // will be set once the fetchRawData function has been called - - /* - below fields represent the processing state of the media thumbnail - */ - - thumbstate processState - thumb *ImageMeta - - /* - below fields represent the processing state of the full-sized media - */ - - fullSizeState processState - fullSize *ImageMeta + thumbstate processState // the processing state of the media thumbnail + fullSizeState processState // the processing state of the full-sized media /* below pointers to database and storage are maintained so that @@ -83,21 +73,22 @@ func (p *ProcessingMedia) AttachmentID() string { // LoadAttachment blocks until the thumbnail and fullsize content // has been processed, and then returns the completed attachment. func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - if err := p.fetchRawData(ctx); err != nil { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.store(ctx); err != nil { return nil, err } - if _, err := p.loadThumb(ctx); err != nil { + if err := p.loadThumb(ctx); err != nil { return nil, err } - if _, err := p.loadFullSize(ctx); err != nil { + if err := p.loadFullSize(ctx); err != nil { return nil, err } // store the result in the database before returning it - p.mu.Lock() - defer p.mu.Unlock() if !p.insertedInDB { if err := p.database.Put(ctx, p.attachment); err != nil { return nil, err @@ -114,10 +105,7 @@ func (p *ProcessingMedia) Finished() bool { return p.thumbstate == complete && p.fullSizeState == complete } -func (p *ProcessingMedia) loadThumb(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - +func (p *ProcessingMedia) loadThumb(ctx context.Context) error { switch p.thumbstate { case received: // we haven't processed a thumbnail for this media yet so do it now @@ -129,87 +117,94 @@ func (p *ProcessingMedia) loadThumb(ctx context.Context) (*ImageMeta, error) { createBlurhash = true } - thumb, err := deriveThumbnail(p.rawData, p.attachment.File.ContentType, createBlurhash) + // stream the original file out of storage... + stored, err := p.storage.GetStream(p.attachment.File.Path) + if err != nil { + p.err = fmt.Errorf("loadThumb: error fetching file from storage: %s", err) + p.thumbstate = errored + return p.err + } + + // ... and into the derive thumbnail function + thumb, err := deriveThumbnail(stored, p.attachment.File.ContentType, createBlurhash) if err != nil { - p.err = fmt.Errorf("error deriving thumbnail: %s", err) + p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) + p.thumbstate = errored + return p.err + } + + if err := stored.Close(); err != nil { + p.err = fmt.Errorf("loadThumb: error closing stored full size: %s", err) p.thumbstate = errored - return nil, p.err + return p.err } // put the thumbnail in storage - if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.image); err != nil { - p.err = fmt.Errorf("error storing thumbnail: %s", err) + if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.small); err != nil { + p.err = fmt.Errorf("loadThumb: error storing thumbnail: %s", err) p.thumbstate = errored - return nil, p.err + return p.err } // set appropriate fields on the attachment based on the thumbnail we derived if createBlurhash { p.attachment.Blurhash = thumb.blurhash } - p.attachment.FileMeta.Small = gtsmodel.Small{ Width: thumb.width, Height: thumb.height, Size: thumb.size, Aspect: thumb.aspect, } - p.attachment.Thumbnail.FileSize = len(thumb.image) - - // set the thumbnail of this media - p.thumb = thumb + p.attachment.Thumbnail.FileSize = len(thumb.small) // we're done processing the thumbnail! p.thumbstate = complete fallthrough case complete: - return p.thumb, nil + return nil case errored: - return nil, p.err + return p.err } - return nil, fmt.Errorf("thumbnail processing status %d unknown", p.thumbstate) + return fmt.Errorf("loadThumb: thumbnail processing status %d unknown", p.thumbstate) } -func (p *ProcessingMedia) loadFullSize(ctx context.Context) (*ImageMeta, error) { - p.mu.Lock() - defer p.mu.Unlock() - +func (p *ProcessingMedia) loadFullSize(ctx context.Context) error { switch p.fullSizeState { case received: - var clean []byte var err error - var decoded *ImageMeta + var decoded *imageMeta + + // stream the original file out of storage... + stored, err := p.storage.GetStream(p.attachment.File.Path) + if err != nil { + p.err = fmt.Errorf("loadFullSize: error fetching file from storage: %s", err) + p.fullSizeState = errored + return p.err + } + // decode the image ct := p.attachment.File.ContentType switch ct { case mimeImageJpeg, mimeImagePng: - // first 'clean' image by purging exif data from it - var exifErr error - if clean, exifErr = purgeExif(p.rawData); exifErr != nil { - err = exifErr - break - } - decoded, err = decodeImage(clean, ct) + decoded, err = decodeImage(stored, ct) case mimeImageGif: - // gifs are already clean - no exif data to remove - clean = p.rawData - decoded, err = decodeGif(clean) + decoded, err = decodeGif(stored) default: - err = fmt.Errorf("content type %s not a processible image type", ct) + err = fmt.Errorf("loadFullSize: content type %s not a processible image type", ct) } if err != nil { p.err = err p.fullSizeState = errored - return nil, err + return p.err } - // put the full size in storage - if err := p.storage.Put(p.attachment.File.Path, decoded.image); err != nil { - p.err = fmt.Errorf("error storing full size image: %s", err) - p.fullSizeState = errored - return nil, p.err + if err := stored.Close(); err != nil { + p.err = fmt.Errorf("loadFullSize: error closing stored full size: %s", err) + p.thumbstate = errored + return p.err } // set appropriate fields on the attachment based on the image we derived @@ -219,56 +214,58 @@ func (p *ProcessingMedia) loadFullSize(ctx context.Context) (*ImageMeta, error) Size: decoded.size, Aspect: decoded.aspect, } - p.attachment.File.FileSize = len(decoded.image) p.attachment.File.UpdatedAt = time.Now() p.attachment.Processing = gtsmodel.ProcessingStatusProcessed - // set the fullsize of this media - p.fullSize = decoded - // we're done processing the full-size image p.fullSizeState = complete fallthrough case complete: - return p.fullSize, nil + return nil case errored: - return nil, p.err + return p.err } - return nil, fmt.Errorf("full size processing status %d unknown", p.fullSizeState) + return fmt.Errorf("loadFullSize: full size processing status %d unknown", p.fullSizeState) } -// fetchRawData calls the data function attached to p if it hasn't been called yet, -// and updates the underlying attachment fields as necessary. -// It should only be called from within a function that already has a lock on p! -func (p *ProcessingMedia) fetchRawData(ctx context.Context) error { +// store calls the data function attached to p if it hasn't been called yet, +// and updates the underlying attachment fields as necessary. It will then stream +// bytes from p's reader directly into storage so that it can be retrieved later. +func (p *ProcessingMedia) store(ctx context.Context) error { // check if we've already done this and bail early if we have - if p.rawData != nil { + if p.read { return nil } - // execute the data function and pin the raw bytes for further processing - b, err := p.data(ctx) + // execute the data function to get the reader out of it + reader, err := p.data(ctx) if err != nil { - return fmt.Errorf("fetchRawData: error executing data function: %s", err) + return fmt.Errorf("store: error executing data function: %s", err) + } + + // extract no more than 261 bytes from the beginning of the file -- this is the header + firstBytes := make([]byte, maxFileHeaderBytes) + if _, err := reader.Read(firstBytes); err != nil { + return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err) } - p.rawData = b - // now we have the data we can work out the content type - contentType, err := parseContentType(p.rawData) + // now we have the file header we can work out the content type from it + contentType, err := parseContentType(firstBytes) if err != nil { - return fmt.Errorf("fetchRawData: error parsing content type: %s", err) + return fmt.Errorf("store: error parsing content type: %s", err) } + // bail if this is a type we can't process if !supportedImage(contentType) { - return fmt.Errorf("fetchRawData: media type %s not (yet) supported", contentType) + return fmt.Errorf("store: media type %s not (yet) supported", contentType) } + // extract the file extension split := strings.Split(contentType, "/") if len(split) != 2 { - return fmt.Errorf("fetchRawData: content type %s was not valid", contentType) + return fmt.Errorf("store: content type %s was not valid", contentType) } - extension := split[1] // something like 'jpeg' // set some additional fields on the attachment now that @@ -282,6 +279,22 @@ func (p *ProcessingMedia) fetchRawData(ctx context.Context) error { p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension) p.attachment.File.ContentType = contentType + // concatenate the first bytes with the existing bytes still in the reader (thanks Mara) + multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader) + + // store this for now -- other processes can pull it out of storage as they please + if err := p.storage.PutStream(p.attachment.File.Path, multiReader); err != nil { + return fmt.Errorf("store: error storing stream: %s", err) + } + + // if the original reader is a readcloser, close it since we're done with it now + if rc, ok := reader.(io.ReadCloser); ok { + if err := rc.Close(); err != nil { + return fmt.Errorf("store: error closing readcloser: %s", err) + } + } + + p.read = true return nil } diff --git a/internal/media/types.go b/internal/media/types.go index 5b3fe4a41..0a7f60d66 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -20,6 +20,7 @@ package media import ( "context" + "io" "time" ) @@ -28,7 +29,7 @@ import ( // // See: https://en.wikipedia.org/wiki/File_format#File_header // and https://github.com/h2non/filetype -const maxFileHeaderBytes = 262 +const maxFileHeaderBytes = 261 // mime consts const ( @@ -117,4 +118,4 @@ type AdditionalEmojiInfo struct { } // DataFunc represents a function used to retrieve the raw bytes of a piece of media. -type DataFunc func(ctx context.Context) ([]byte, error) +type DataFunc func(ctx context.Context) (io.Reader, error) diff --git a/internal/media/util.go b/internal/media/util.go index 7a3d81c0f..248d5fb19 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -19,7 +19,6 @@ package media import ( - "bytes" "errors" "fmt" @@ -28,11 +27,11 @@ import ( // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). // Returns an error if the content type is not something we can process. -func parseContentType(content []byte) (string, error) { - // read in the first bytes of the file - fileHeader := make([]byte, maxFileHeaderBytes) - if _, err := bytes.NewReader(content).Read(fileHeader); err != nil { - return "", fmt.Errorf("could not read first magic bytes of file: %s", err) +// +// Fileheader should be no longer than 262 bytes; anything more than this is inefficient. +func parseContentType(fileHeader []byte) (string, error) { + if fhLength := len(fileHeader); fhLength > maxFileHeaderBytes { + return "", fmt.Errorf("parseContentType requires %d bytes max, we got %d", maxFileHeaderBytes, fhLength) } kind, err := filetype.Match(fileHeader) diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 7b305dc95..5a0a3e5a1 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -19,9 +19,7 @@ package account import ( - "bytes" "context" - "errors" "fmt" "io" "mime/multipart" @@ -142,24 +140,8 @@ func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHead return nil, fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) } - dataFunc := func(ctx context.Context) ([]byte, error) { - // pop open the fileheader - f, err := avatar.Open() - if err != nil { - return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("UpdateAvatar: could not read provided avatar: %s", err) - } - if size == 0 { - return nil, errors.New("UpdateAvatar: could not read provided avatar: size 0 bytes") - } - - return buf.Bytes(), f.Close() + dataFunc := func(ctx context.Context) (io.Reader, error) { + return avatar.Open() } isAvatar := true @@ -184,24 +166,8 @@ func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHead return nil, fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) } - dataFunc := func(ctx context.Context) ([]byte, error) { - // pop open the fileheader - f, err := header.Open() - if err != nil { - return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("UpdateHeader: could not read provided header: %s", err) - } - if size == 0 { - return nil, errors.New("UpdateHeader: could not read provided header: size 0 bytes") - } - - return buf.Bytes(), f.Close() + dataFunc := func(ctx context.Context) (io.Reader, error) { + return header.Open() } isHeader := true diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index fcc17c4be..e0068858b 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -19,9 +19,7 @@ package admin import ( - "bytes" "context" - "errors" "fmt" "io" @@ -38,22 +36,8 @@ func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin") } - data := func(innerCtx context.Context) ([]byte, error) { - // open the emoji and extract the bytes from it - f, err := form.Image.Open() - if err != nil { - return nil, fmt.Errorf("error opening emoji: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading emoji: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided emoji: size 0 bytes") - } - - return buf.Bytes(), f.Close() + data := func(innerCtx context.Context) (io.Reader, error) { + return form.Image.Open() } emojiID, err := id.NewRandomULID() diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 0896315b1..0fda4c27b 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -19,9 +19,7 @@ package media import ( - "bytes" "context" - "errors" "fmt" "io" @@ -31,21 +29,8 @@ import ( ) func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { - data := func(innerCtx context.Context) ([]byte, error) { - // open the attachment and extract the bytes from it - f, err := form.File.Open() - if err != nil { - return nil, fmt.Errorf("error opening attachment: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading attachment: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided attachment: size 0 bytes") - } - return buf.Bytes(), f.Close() + data := func(innerCtx context.Context) (io.Reader, error) { + return form.File.Open() } focusX, focusY, err := parseFocus(form.Focus) diff --git a/internal/transport/derefmedia.go b/internal/transport/derefmedia.go index 3fa4a89e4..ed32f20c6 100644 --- a/internal/transport/derefmedia.go +++ b/internal/transport/derefmedia.go @@ -21,14 +21,14 @@ package transport import ( "context" "fmt" - "io/ioutil" + "io" "net/http" "net/url" "github.com/sirupsen/logrus" ) -func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, error) { +func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, error) { l := logrus.WithField("func", "DereferenceMedia") l.Debugf("performing GET to %s", iri.String()) req, err := http.NewRequestWithContext(ctx, "GET", iri.String(), nil) @@ -50,9 +50,8 @@ func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, if err != nil { return nil, err } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET request to %s failed (%d): %s", iri.String(), resp.StatusCode, resp.Status) } - return ioutil.ReadAll(resp.Body) + return resp.Body, nil } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index c43515a42..d9650d952 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -21,6 +21,7 @@ package transport import ( "context" "crypto" + "io" "net/url" "sync" @@ -33,8 +34,8 @@ import ( // functionality for fetching remote media. type Transport interface { pub.Transport - // DereferenceMedia fetches the bytes of the given media attachment IRI. - DereferenceMedia(ctx context.Context, iri *url.URL) ([]byte, error) + // DereferenceMedia fetches the given media attachment IRI. + DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, error) // DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo. DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error) // Finger performs a webfinger request with the given username and domain, and returns the bytes from the response body. diff --git a/testrig/storage.go b/testrig/storage.go index a8cf0d838..0e91d7dbe 100644 --- a/testrig/storage.go +++ b/testrig/storage.go @@ -19,20 +19,16 @@ package testrig import ( - "bytes" - "errors" "fmt" - "io" "os" "codeberg.org/gruf/go-store/kv" "codeberg.org/gruf/go-store/storage" - "codeberg.org/gruf/go-store/util" ) // NewTestStorage returns a new in memory storage with the default test config func NewTestStorage() *kv.KVStore { - storage, err := kv.OpenStorage(&inMemStorage{storage: map[string][]byte{}, overwrite: false}) + storage, err := kv.OpenStorage(storage.OpenMemory(200, false)) if err != nil { panic(err) } @@ -113,79 +109,3 @@ func StandardStorageTeardown(s *kv.KVStore) { } } } - -type inMemStorage struct { - storage map[string][]byte - overwrite bool -} - -func (s *inMemStorage) Clean() error { - return nil -} - -func (s *inMemStorage) ReadBytes(key string) ([]byte, error) { - b, ok := s.storage[key] - if !ok { - return nil, errors.New("key not found") - } - return b, nil -} - -func (s *inMemStorage) ReadStream(key string) (io.ReadCloser, error) { - b, err := s.ReadBytes(key) - if err != nil { - return nil, err - } - return util.NopReadCloser(bytes.NewReader(b)), nil -} - -func (s *inMemStorage) WriteBytes(key string, value []byte) error { - if _, ok := s.storage[key]; ok && !s.overwrite { - return errors.New("key already in storage") - } - s.storage[key] = copyBytes(value) - return nil -} - -func (s *inMemStorage) WriteStream(key string, r io.Reader) error { - b, err := io.ReadAll(r) - if err != nil { - return err - } - return s.WriteBytes(key, b) -} - -func (s *inMemStorage) Stat(key string) (bool, error) { - _, ok := s.storage[key] - return ok, nil -} - -func (s *inMemStorage) Remove(key string) error { - if _, ok := s.storage[key]; !ok { - return errors.New("key not found") - } - delete(s.storage, key) - return nil -} - -func (s *inMemStorage) WalkKeys(opts storage.WalkKeysOptions) error { - if opts.WalkFn == nil { - return errors.New("invalid walkfn") - } - for key := range s.storage { - opts.WalkFn(entry(key)) - } - return nil -} - -type entry string - -func (e entry) Key() string { - return string(e) -} - -func copyBytes(b []byte) []byte { - p := make([]byte, len(b)) - copy(p, b) - return p -} -- cgit v1.2.3 From c157b1b20b38cc331cfd1673433d077719feef3f Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Sun, 23 Jan 2022 14:41:58 +0100 Subject: rework data function to provide filesize --- internal/federation/dereferencing/account.go | 4 ++-- internal/federation/dereferencing/media.go | 2 +- internal/media/image.go | 20 ----------------- internal/media/manager_test.go | 12 +++++----- internal/media/processingemoji.go | 4 ++-- internal/media/processingmedia.go | 33 ++++++++++++++++++++-------- internal/media/types.go | 2 +- internal/processing/account/update.go | 10 +++++---- internal/processing/admin/emoji.go | 5 +++-- internal/processing/media/create.go | 5 +++-- internal/transport/derefmedia.go | 12 +++++----- internal/transport/transport.go | 4 ++-- 12 files changed, 56 insertions(+), 57 deletions(-) (limited to 'internal/processing/media/create.go') diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 6ea8256d5..581c95de2 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -252,7 +252,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data := func(innerCtx context.Context) (io.Reader, error) { + data := func(innerCtx context.Context) (io.Reader, int, error) { return t.DereferenceMedia(innerCtx, avatarIRI) } @@ -274,7 +274,7 @@ func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount * return err } - data := func(innerCtx context.Context) (io.Reader, error) { + data := func(innerCtx context.Context) (io.Reader, int, error) { return t.DereferenceMedia(innerCtx, headerIRI) } diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index c427f2507..0b19570f2 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -42,7 +42,7 @@ func (d *deref) GetRemoteMedia(ctx context.Context, requestingUsername string, a return nil, fmt.Errorf("GetRemoteMedia: error parsing url: %s", err) } - dataFunc := func(innerCtx context.Context) (io.Reader, error) { + dataFunc := func(innerCtx context.Context) (io.Reader, int, error) { return t.DereferenceMedia(innerCtx, derefURI) } diff --git a/internal/media/image.go b/internal/media/image.go index b8f00024f..e5390cee5 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -30,7 +30,6 @@ import ( "github.com/buckket/go-blurhash" "github.com/nfnt/resize" - "github.com/superseriousbusiness/exifremove/pkg/exifremove" ) const ( @@ -197,22 +196,3 @@ func deriveStaticEmoji(r io.Reader, contentType string) (*imageMeta, error) { small: out.Bytes(), }, nil } - -// purgeExif is a little wrapper for the action of removing exif data from an image. -// Only pass pngs or jpegs to this function. -func purgeExif(data []byte) ([]byte, error) { - if len(data) == 0 { - return nil, errors.New("passed image was not valid") - } - - clean, err := exifremove.Remove(data) - if err != nil { - return nil, fmt.Errorf("could not purge exif from image: %s", err) - } - - if len(clean) == 0 { - return nil, errors.New("purged image was not valid") - } - - return clean, nil -} diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 5380b83b1..960f34843 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -39,13 +39,13 @@ type ManagerTestSuite struct { func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { ctx := context.Background() - data := func(_ context.Context) (io.Reader, error) { + data := func(_ context.Context) (io.Reader, int, error) { // load bytes from a test image b, err := os.ReadFile("./test/test-jpeg.jpg") if err != nil { panic(err) } - return bytes.NewBuffer(b), nil + return bytes.NewBuffer(b), len(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" @@ -109,13 +109,13 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { ctx := context.Background() - data := func(_ context.Context) (io.Reader, error) { + data := func(_ context.Context) (io.Reader, int, error) { // load bytes from a test image b, err := os.ReadFile("./test/test-jpeg.jpg") if err != nil { panic(err) } - return bytes.NewBuffer(b), nil + return bytes.NewBuffer(b), len(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" @@ -192,9 +192,9 @@ func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() { panic(err) } - data := func(_ context.Context) (io.Reader, error) { + data := func(_ context.Context) (io.Reader, int, error) { // load bytes from a test image - return bytes.NewReader(b), nil + return bytes.NewReader(b), len(b), nil } accountID := "01FS1X72SK9ZPW0J1QQ68BD264" diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index 147b6b5b3..292712427 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -163,7 +163,7 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { } // execute the data function to get the reader out of it - reader, err := p.data(ctx) + reader, fileSize, err := p.data(ctx) if err != nil { return fmt.Errorf("store: error executing data function: %s", err) } @@ -194,6 +194,7 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), p.emoji.ID, extension) p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, p.emoji.ID, extension) p.emoji.ImageContentType = contentType + p.emoji.ImageFileSize = fileSize // concatenate the first bytes with the existing bytes still in the reader (thanks Mara) multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader) @@ -202,7 +203,6 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { if err := p.storage.PutStream(p.emoji.ImagePath, multiReader); err != nil { return fmt.Errorf("store: error storing stream: %s", err) } - p.emoji.ImageFileSize = 36702 // TODO: set this based on the result of PutStream // if the original reader is a readcloser, close it since we're done with it now if rc, ok := reader.(io.ReadCloser); ok { diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index 82db863e0..0bbe35aee 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -28,6 +28,7 @@ import ( "time" "codeberg.org/gruf/go-store/kv" + terminator "github.com/superseriousbusiness/exif-terminator" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" @@ -239,7 +240,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } // execute the data function to get the reader out of it - reader, err := p.data(ctx) + reader, fileSize, err := p.data(ctx) if err != nil { return fmt.Errorf("store: error executing data function: %s", err) } @@ -268,22 +269,36 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } extension := split[1] // something like 'jpeg' - // set some additional fields on the attachment now that - // we know more about what the underlying media actually is - if extension == mimeGif { + // concatenate the cleaned up first bytes with the existing bytes still in the reader (thanks Mara) + multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader) + + // we'll need to clean exif data from the first bytes; while we're + // here, we can also use the extension to derive the attachment type + var clean io.Reader + switch extension { + case mimeGif: p.attachment.Type = gtsmodel.FileTypeGif - } else { + clean = multiReader // nothing to clean from a gif + case mimeJpeg, mimePng: p.attachment.Type = gtsmodel.FileTypeImage + purged, err := terminator.Terminate(multiReader, fileSize, extension) + if err != nil { + return fmt.Errorf("store: exif error: %s", err) + } + clean = purged + default: + return fmt.Errorf("store: couldn't process %s", extension) } + + // now set some additional fields on the attachment since + // we know more about what the underlying media actually is p.attachment.URL = uris.GenerateURIForAttachment(p.attachment.AccountID, string(TypeAttachment), string(SizeOriginal), p.attachment.ID, extension) p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension) p.attachment.File.ContentType = contentType - - // concatenate the first bytes with the existing bytes still in the reader (thanks Mara) - multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader) + p.attachment.File.FileSize = fileSize // store this for now -- other processes can pull it out of storage as they please - if err := p.storage.PutStream(p.attachment.File.Path, multiReader); err != nil { + if err := p.storage.PutStream(p.attachment.File.Path, clean); err != nil { return fmt.Errorf("store: error storing stream: %s", err) } diff --git a/internal/media/types.go b/internal/media/types.go index 0a7f60d66..b9c79d464 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -118,4 +118,4 @@ type AdditionalEmojiInfo struct { } // DataFunc represents a function used to retrieve the raw bytes of a piece of media. -type DataFunc func(ctx context.Context) (io.Reader, error) +type DataFunc func(ctx context.Context) (reader io.Reader, fileSize int, err error) diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 5a0a3e5a1..758cc6600 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -140,8 +140,9 @@ func (p *processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHead return nil, fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize) } - dataFunc := func(ctx context.Context) (io.Reader, error) { - return avatar.Open() + dataFunc := func(ctx context.Context) (io.Reader, int, error) { + f, err := avatar.Open() + return f, int(avatar.Size), err } isAvatar := true @@ -166,8 +167,9 @@ func (p *processor) UpdateHeader(ctx context.Context, header *multipart.FileHead return nil, fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize) } - dataFunc := func(ctx context.Context) (io.Reader, error) { - return header.Open() + dataFunc := func(ctx context.Context) (io.Reader, int, error) { + f, err := header.Open() + return f, int(header.Size), err } isHeader := true diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index e0068858b..bb9f4ecb5 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -36,8 +36,9 @@ func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin") } - data := func(innerCtx context.Context) (io.Reader, error) { - return form.Image.Open() + data := func(innerCtx context.Context) (io.Reader, int, error) { + f, err := form.Image.Open() + return f, int(form.Image.Size), err } emojiID, err := id.NewRandomULID() diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 0fda4c27b..4047278eb 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -29,8 +29,9 @@ import ( ) func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { - data := func(innerCtx context.Context) (io.Reader, error) { - return form.File.Open() + data := func(innerCtx context.Context) (io.Reader, int, error) { + f, err := form.File.Open() + return f, int(form.File.Size), err } focusX, focusY, err := parseFocus(form.Focus) diff --git a/internal/transport/derefmedia.go b/internal/transport/derefmedia.go index ed32f20c6..e3c86ce1e 100644 --- a/internal/transport/derefmedia.go +++ b/internal/transport/derefmedia.go @@ -28,12 +28,12 @@ import ( "github.com/sirupsen/logrus" ) -func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, error) { +func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, int, error) { l := logrus.WithField("func", "DereferenceMedia") l.Debugf("performing GET to %s", iri.String()) req, err := http.NewRequestWithContext(ctx, "GET", iri.String(), nil) if err != nil { - return nil, err + return nil, 0, err } req.Header.Add("Accept", "*/*") // we don't know what kind of media we're going to get here @@ -44,14 +44,14 @@ func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.Read err = t.getSigner.SignRequest(t.privkey, t.pubKeyID, req, nil) t.getSignerMu.Unlock() if err != nil { - return nil, err + return nil, 0, err } resp, err := t.client.Do(req) if err != nil { - return nil, err + return nil, 0, err } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET request to %s failed (%d): %s", iri.String(), resp.StatusCode, resp.Status) + return nil, 0, fmt.Errorf("GET request to %s failed (%d): %s", iri.String(), resp.StatusCode, resp.Status) } - return resp.Body, nil + return resp.Body, int(resp.ContentLength), nil } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index d9650d952..9e8cd8213 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -34,8 +34,8 @@ import ( // functionality for fetching remote media. type Transport interface { pub.Transport - // DereferenceMedia fetches the given media attachment IRI. - DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, error) + // DereferenceMedia fetches the given media attachment IRI, returning the reader and filesize. + DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, int, error) // DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo. DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error) // Finger performs a webfinger request with the given username and domain, and returns the bytes from the response body. -- cgit v1.2.3