diff options
Diffstat (limited to 'internal')
78 files changed, 2710 insertions, 1599 deletions
| 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/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/account/accountverify_test.go b/internal/api/client/account/accountverify_test.go index 702eb0836..b5dfdd5e8 100644 --- a/internal/api/client/account/accountverify_test.go +++ b/internal/api/client/account/accountverify_test.go @@ -42,7 +42,7 @@ func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() {  	// set up the request  	recorder := httptest.NewRecorder() -	ctx := suite.newContext(recorder, http.MethodPatch, nil, account.UpdateCredentialsPath, "") +	ctx := suite.newContext(recorder, http.MethodGet, nil, account.VerifyPath, "")  	// call the handler  	suite.accountModule.AccountVerifyGETHandler(ctx) 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 <http://www.gnu.org/licenses/>. +*/ + +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 617add413..ef42d0a13 100644 --- a/internal/api/client/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -27,12 +27,11 @@ 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"  ) -// 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.  // @@ -74,7 +73,9 @@ import (  //      description: forbidden  //   '400':  //      description: bad request -func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { +//   '409': +//      description: conflict -- domain/shortcode combo for emoji already exists +func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) {  	l := logrus.WithFields(logrus.Fields{  		"func":        "emojiCreatePOSTHandler",  		"request_uri": c.Request.RequestURI, @@ -117,10 +118,10 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {  		return  	} -	apiEmoji, err := m.processor.AdminEmojiCreate(c.Request.Context(), authed, form) -	if err != nil { -		l.Debugf("error creating emoji: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +	apiEmoji, errWithCode := m.processor.AdminEmojiCreate(c.Request.Context(), authed, form) +	if errWithCode != nil { +		l.Debugf("error creating emoji: %s", errWithCode.Error()) +		c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})  		return  	} @@ -133,10 +134,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/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go new file mode 100644 index 000000000..2b7476da1 --- /dev/null +++ b/internal/api/client/admin/emojicreate_test.go @@ -0,0 +1,128 @@ +package admin_test + +import ( +	"context" +	"encoding/json" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"testing" + +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"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": "new_emoji", +		}) +	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) + +	// response should be an api model emoji +	apiEmoji := &apimodel.Emoji{} +	err = json.Unmarshal(b, apiEmoji) +	suite.NoError(err) + +	// appropriate fields should be set +	suite.Equal("new_emoji", apiEmoji.Shortcode) +	suite.NotEmpty(apiEmoji.URL) +	suite.NotEmpty(apiEmoji.StaticURL) +	suite.True(apiEmoji.VisibleInPicker) + +	// emoji should be in the db +	dbEmoji := >smodel.Emoji{} +	err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "shortcode", Value: "new_emoji"}}, dbEmoji) +	suite.NoError(err) + +	// check fields on the emoji +	suite.NotEmpty(dbEmoji.ID) +	suite.Equal("new_emoji", dbEmoji.Shortcode) +	suite.Empty(dbEmoji.Domain) +	suite.Empty(dbEmoji.ImageRemoteURL) +	suite.Empty(dbEmoji.ImageStaticRemoteURL) +	suite.Equal(apiEmoji.URL, dbEmoji.ImageURL) +	suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL) +	suite.NotEmpty(dbEmoji.ImagePath) +	suite.NotEmpty(dbEmoji.ImageStaticPath) +	suite.Equal("image/png", dbEmoji.ImageContentType) +	suite.Equal("image/png", dbEmoji.ImageStaticContentType) +	suite.Equal(36702, dbEmoji.ImageFileSize) +	suite.Equal(10413, dbEmoji.ImageStaticFileSize) +	suite.False(dbEmoji.Disabled) +	suite.NotEmpty(dbEmoji.URI) +	suite.True(dbEmoji.VisibleInPicker) +	suite.Empty(dbEmoji.CategoryID) + +	// emoji should be in storage +	emojiBytes, err := suite.storage.Get(dbEmoji.ImagePath) +	suite.NoError(err) +	suite.Len(emojiBytes, dbEmoji.ImageFileSize) +	emojiStaticBytes, err := suite.storage.Get(dbEmoji.ImageStaticPath) +	suite.NoError(err) +	suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiCreateTestSuite) TestEmojiCreateAlreadyExists() { +	// set up the request -- use a shortcode that already exists for an emoji in the database +	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) + +	suite.Equal(http.StatusConflict, recorder.Code) + +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) +	suite.NotEmpty(b) + +	suite.Equal(`{"error":"conflict: emoji with shortcode rainbow already exists"}`, string(b)) +} + +func TestEmojiCreateTestSuite(t *testing.T) { +	suite.Run(t, &EmojiCreateTestSuite{}) +} diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go index 18f54542a..5cd4b529b 100644 --- a/internal/api/client/fileserver/servefile_test.go +++ b/internal/api/client/fileserver/servefile_test.go @@ -51,7 +51,7 @@ type ServeFileTestSuite struct {  	federator    federation.Federator  	tc           typeutils.TypeConverter  	processor    processing.Processor -	mediaHandler media.Handler +	mediaManager media.Manager  	oauthServer  oauth.Server  	emailSender  email.Sender @@ -77,12 +77,12 @@ 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.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) +	suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)  	suite.oauthServer = testrig.NewTestOauthServer(suite.db)  	// setup module being tested 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 2d8042e33..ca0b6483f 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -54,9 +54,9 @@ type MediaCreateTestSuite struct {  	suite.Suite  	db           db.DB  	storage      *kv.KVStore +	mediaManager media.Manager  	federator    federation.Federator  	tc           typeutils.TypeConverter -	mediaHandler media.Handler  	oauthServer  oauth.Server  	emailSender  email.Sender  	processor    processing.Processor @@ -84,11 +84,11 @@ func (suite *MediaCreateTestSuite) SetupSuite() {  	suite.db = testrig.NewTestDB()  	suite.storage = testrig.NewTestStorage()  	suite.tc = testrig.NewTestTypeConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) +	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/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index cac6c304e..b99c89c06 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -54,7 +54,7 @@ type MediaUpdateTestSuite struct {  	storage      *kv.KVStore  	federator    federation.Federator  	tc           typeutils.TypeConverter -	mediaHandler media.Handler +	mediaManager media.Manager  	oauthServer  oauth.Server  	emailSender  email.Sender  	processor    processing.Processor @@ -82,11 +82,11 @@ func (suite *MediaUpdateTestSuite) SetupSuite() {  	suite.db = testrig.NewTestDB()  	suite.storage = testrig.NewTestStorage()  	suite.tc = testrig.NewTestTypeConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) +	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/api/s2s/webfinger/webfingerget_test.go b/internal/api/s2s/webfinger/webfingerget_test.go index d3b0c32e8..3d271a260 100644 --- a/internal/api/s2s/webfinger/webfingerget_test.go +++ b/internal/api/s2s/webfinger/webfingerget_test.go @@ -69,7 +69,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUser() {  func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() {  	viper.Set(config.Keys.Host, "gts.example.org")  	viper.Set(config.Keys.AccountDomain, "example.org") -	suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender) +	suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender)  	suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module)  	targetAccount := accountDomainAccount() @@ -103,7 +103,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHo  func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() {  	viper.Set(config.Keys.Host, "gts.example.org")  	viper.Set(config.Keys.AccountDomain, "example.org") -	suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender) +	suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender)  	suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module)  	targetAccount := accountDomainAccount() diff --git a/internal/db/bundb/errors.go b/internal/db/bundb/errors.go index d01720731..67a673e15 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.NewErrAlreadyExists(err.Error())  	default:  		return err 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/dereference.go b/internal/federation/dereference.go index 343ddadb7..8cb23a91f 100644 --- a/internal/federation/dereference.go +++ b/internal/federation/dereference.go @@ -26,12 +26,8 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  ) -func (f *federator) GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, refresh bool) (*gtsmodel.Account, bool, error) { -	return f.dereferencer.GetRemoteAccount(ctx, username, remoteAccountID, refresh) -} - -func (f *federator) EnrichRemoteAccount(ctx context.Context, username string, account *gtsmodel.Account) (*gtsmodel.Account, error) { -	return f.dereferencer.EnrichRemoteAccount(ctx, username, account) +func (f *federator) GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, blocking bool, refresh bool) (*gtsmodel.Account, error) { +	return f.dereferencer.GetRemoteAccount(ctx, username, remoteAccountID, blocking, refresh)  }  func (f *federator) GetRemoteStatus(ctx context.Context, username string, remoteStatusID *url.URL, refresh, includeParent bool) (*gtsmodel.Status, ap.Statusable, bool, error) { diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index d06ad21c1..02afd9a9c 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -23,8 +23,11 @@ import (  	"encoding/json"  	"errors"  	"fmt" +	"io"  	"net/url"  	"strings" +	"sync" +	"time"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/activity/streams" @@ -32,6 +35,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"  ) @@ -42,94 +46,97 @@ func instanceAccount(account *gtsmodel.Account) bool {  		(account.Username == "internal.fetch" && strings.Contains(account.Note, "internal service actor"))  } -// EnrichRemoteAccount takes an account that's already been inserted into the database in a minimal form, -// and populates it with additional fields, media, etc. -// -// EnrichRemoteAccount is mostly useful for calling after an account has been initially created by -// the federatingDB's Create function, or during the federated authorization flow. -func (d *deref) EnrichRemoteAccount(ctx context.Context, username string, account *gtsmodel.Account) (*gtsmodel.Account, error) { -	// if we're dealing with an instance account, we don't need to update anything -	if instanceAccount(account) { -		return account, nil -	} - -	if err := d.PopulateAccountFields(ctx, account, username, false); err != nil { -		return nil, err -	} - -	updated, err := d.db.UpdateAccount(ctx, account) -	if err != nil { -		logrus.Errorf("EnrichRemoteAccount: error updating account: %s", err) -		return account, nil -	} - -	return updated, nil -} -  // GetRemoteAccount completely dereferences a remote account, converts it to a GtS model account, -// puts it in the database, and returns it to a caller. The boolean indicates whether the account is new -// to us or not. If we haven't seen the account before, bool will be true. If we have seen the account before, -// it will be false. +// puts it in the database, and returns it to a caller.  //  // Refresh indicates whether--if the account exists in our db already--it should be refreshed by calling -// the remote instance again. +// the remote instance again. Blocking indicates whether the function should block until processing of +// the fetched account is complete.  //  // SIDE EFFECTS: remote account will be stored in the database, or updated if it already exists (and refresh is true). -func (d *deref) GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, refresh bool) (*gtsmodel.Account, bool, error) { +func (d *deref) GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, blocking bool, refresh bool) (*gtsmodel.Account, error) {  	new := true -	// check if we already have the account in our db -	maybeAccount, err := d.db.GetAccountByURI(ctx, remoteAccountID.String()) +	// check if we already have the account in our db, and just return it unless we'd doing a refresh +	remoteAccount, err := d.db.GetAccountByURI(ctx, remoteAccountID.String())  	if err == nil { -		// we've seen this account before so it's not new  		new = false  		if !refresh { -			// we're not being asked to refresh, but just in case we don't have the avatar/header cached yet.... -			maybeAccount, err = d.EnrichRemoteAccount(ctx, username, maybeAccount) -			return maybeAccount, new, err +			// make sure the account fields are populated before returning: +			// even if we're not doing a refresh, the caller might want to block +			// until everything is loaded +			changed, err := d.populateAccountFields(ctx, remoteAccount, username, refresh, blocking) +			if err != nil { +				return nil, fmt.Errorf("GetRemoteAccount: error populating remoteAccount fields: %s", err) +			} + +			if changed { +				updatedAccount, err := d.db.UpdateAccount(ctx, remoteAccount) +				if err != nil { +					return nil, fmt.Errorf("GetRemoteAccount: error updating remoteAccount: %s", err) +				} +				return updatedAccount, err +			} + +			return remoteAccount, nil  		}  	} -	accountable, err := d.dereferenceAccountable(ctx, username, remoteAccountID) -	if err != nil { -		return nil, new, fmt.Errorf("FullyDereferenceAccount: error dereferencing accountable: %s", err) -	} +	if new { +		// we haven't seen this account before: dereference it from remote +		accountable, err := d.dereferenceAccountable(ctx, username, remoteAccountID) +		if err != nil { +			return nil, fmt.Errorf("GetRemoteAccount: error dereferencing accountable: %s", err) +		} -	gtsAccount, err := d.typeConverter.ASRepresentationToAccount(ctx, accountable, refresh) -	if err != nil { -		return nil, new, fmt.Errorf("FullyDereferenceAccount: error converting accountable to account: %s", err) -	} +		newAccount, err := d.typeConverter.ASRepresentationToAccount(ctx, accountable, refresh) +		if err != nil { +			return nil, fmt.Errorf("GetRemoteAccount: error converting accountable to account: %s", err) +		} -	if new { -		// generate a new id since we haven't seen this account before, and do a put  		ulid, err := id.NewRandomULID()  		if err != nil { -			return nil, new, fmt.Errorf("FullyDereferenceAccount: error generating new id for account: %s", err) +			return nil, fmt.Errorf("GetRemoteAccount: error generating new id for account: %s", err)  		} -		gtsAccount.ID = ulid +		newAccount.ID = ulid -		if err := d.PopulateAccountFields(ctx, gtsAccount, username, refresh); err != nil { -			return nil, new, fmt.Errorf("FullyDereferenceAccount: error populating further account fields: %s", err) +		if _, err := d.populateAccountFields(ctx, newAccount, username, refresh, blocking); err != nil { +			return nil, fmt.Errorf("GetRemoteAccount: error populating further account fields: %s", err)  		} -		if err := d.db.Put(ctx, gtsAccount); err != nil { -			return nil, new, fmt.Errorf("FullyDereferenceAccount: error putting new account: %s", err) +		if err := d.db.Put(ctx, newAccount); err != nil { +			return nil, fmt.Errorf("GetRemoteAccount: error putting new account: %s", err)  		} -	} else { -		// take the id we already have and do an update -		gtsAccount.ID = maybeAccount.ID -		if err := d.PopulateAccountFields(ctx, gtsAccount, username, refresh); err != nil { -			return nil, new, fmt.Errorf("FullyDereferenceAccount: error populating further account fields: %s", err) -		} +		return newAccount, nil +	} -		gtsAccount, err = d.db.UpdateAccount(ctx, gtsAccount) +	// we have seen this account before, but we have to refresh it +	refreshedAccountable, err := d.dereferenceAccountable(ctx, username, remoteAccountID) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteAccount: error dereferencing refreshedAccountable: %s", err) +	} + +	refreshedAccount, err := d.typeConverter.ASRepresentationToAccount(ctx, refreshedAccountable, refresh) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteAccount: error converting refreshedAccountable to refreshedAccount: %s", err) +	} +	refreshedAccount.ID = remoteAccount.ID + +	changed, err := d.populateAccountFields(ctx, refreshedAccount, username, refresh, blocking) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteAccount: error populating further refreshedAccount fields: %s", err) +	} + +	if changed { +		updatedAccount, err := d.db.UpdateAccount(ctx, refreshedAccount)  		if err != nil { -			return nil, false, fmt.Errorf("EnrichRemoteAccount: error updating account: %s", err) +			return nil, fmt.Errorf("GetRemoteAccount: error updating refreshedAccount: %s", err)  		} +		return updatedAccount, nil  	} -	return gtsAccount, new, nil +	return refreshedAccount, nil  }  // dereferenceAccountable calls remoteAccountID with a GET request, and tries to parse whatever @@ -200,71 +207,189 @@ func (d *deref) dereferenceAccountable(ctx context.Context, username string, rem  	return nil, fmt.Errorf("DereferenceAccountable: type name %s not supported", t.GetTypeName())  } -// PopulateAccountFields populates any fields on the given account that weren't populated by the initial +// populateAccountFields populates any fields on the given account that weren't populated by the initial  // dereferencing. This includes things like header and avatar etc. -func (d *deref) PopulateAccountFields(ctx context.Context, account *gtsmodel.Account, requestingUsername string, refresh bool) error { -	l := logrus.WithFields(logrus.Fields{ -		"func":               "PopulateAccountFields", -		"requestingUsername": requestingUsername, -	}) +func (d *deref) populateAccountFields(ctx context.Context, account *gtsmodel.Account, requestingUsername string, blocking bool, refresh bool) (bool, error) { +	// if we're dealing with an instance account, just bail, we don't need to do anything +	if instanceAccount(account) { +		return false, nil +	}  	accountURI, err := url.Parse(account.URI)  	if err != nil { -		return fmt.Errorf("PopulateAccountFields: couldn't parse account URI %s: %s", account.URI, err) +		return false, fmt.Errorf("populateAccountFields: couldn't parse account URI %s: %s", account.URI, err)  	} +  	if blocked, err := d.db.IsDomainBlocked(ctx, accountURI.Host); blocked || err != nil { -		return fmt.Errorf("PopulateAccountFields: domain %s is blocked", accountURI.Host) +		return false, fmt.Errorf("populateAccountFields: domain %s is blocked", accountURI.Host)  	}  	t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername)  	if err != nil { -		return fmt.Errorf("PopulateAccountFields: error getting transport for user: %s", err) +		return false, fmt.Errorf("populateAccountFields: error getting transport for user: %s", err)  	}  	// fetch the header and avatar -	if err := d.fetchHeaderAndAviForAccount(ctx, account, t, refresh); err != nil { -		// if this doesn't work, just skip it -- we can do it later -		l.Debugf("error fetching header/avi for account: %s", err) +	changed, err := d.fetchRemoteAccountMedia(ctx, account, t, refresh, blocking) +	if err != nil { +		return false, fmt.Errorf("populateAccountFields: error fetching header/avi for account: %s", err)  	} -	return nil +	return changed, nil  } -// fetchHeaderAndAviForAccount fetches the header and avatar for a remote account, using a transport -// on behalf of requestingUsername. +// fetchRemoteAccountMedia fetches and stores the header and avatar for a remote account, +// using a transport on behalf of requestingUsername. +// +// The returned boolean indicates whether anything changed -- in other words, whether the +// account should be updated in the database.  //  // targetAccount's AvatarMediaAttachmentID and HeaderMediaAttachmentID will be updated as necessary.  // -// SIDE EFFECTS: remote header and avatar will be stored in local storage. -func (d *deref) fetchHeaderAndAviForAccount(ctx context.Context, targetAccount *gtsmodel.Account, t transport.Transport, refresh bool) error { +// If refresh is true, then the media will be fetched again even if it's already been fetched before. +// +// If blocking is true, then the calls to the media manager made by this function will be blocking: +// in other words, the function won't return until the header and the avatar have been fully processed. +func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsmodel.Account, t transport.Transport, blocking bool, refresh bool) (bool, error) { +	changed := false +  	accountURI, err := url.Parse(targetAccount.URI)  	if err != nil { -		return fmt.Errorf("fetchHeaderAndAviForAccount: couldn't parse account URI %s: %s", targetAccount.URI, err) +		return changed, fmt.Errorf("fetchRemoteAccountMedia: couldn't parse account URI %s: %s", targetAccount.URI, err)  	} +  	if blocked, err := d.db.IsDomainBlocked(ctx, accountURI.Host); blocked || err != nil { -		return fmt.Errorf("fetchHeaderAndAviForAccount: domain %s is blocked", accountURI.Host) +		return changed, fmt.Errorf("fetchRemoteAccountMedia: domain %s is blocked", accountURI.Host)  	}  	if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "" || refresh) { -		a, err := d.mediaHandler.ProcessRemoteHeaderOrAvatar(ctx, t, >smodel.MediaAttachment{ -			RemoteURL: targetAccount.AvatarRemoteURL, -			Avatar:    true, -		}, targetAccount.ID) -		if err != nil { -			return fmt.Errorf("error processing avatar for user: %s", err) +		var processingMedia *media.ProcessingMedia + +		d.dereferencingAvatarsLock.Lock() // LOCK HERE +		// first check if we're already processing this media +		if alreadyProcessing, ok := d.dereferencingAvatars[targetAccount.ID]; ok { +			// we're already on it, no worries +			processingMedia = alreadyProcessing  		} -		targetAccount.AvatarMediaAttachmentID = a.ID + +		if processingMedia == nil { +			// we're not already processing it so start now +			avatarIRI, err := url.Parse(targetAccount.AvatarRemoteURL) +			if err != nil { +				d.dereferencingAvatarsLock.Unlock() +				return changed, err +			} + +			data := func(innerCtx context.Context) (io.Reader, int, error) { +				return t.DereferenceMedia(innerCtx, avatarIRI) +			} + +			avatar := true +			newProcessing, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalMediaInfo{ +				RemoteURL: &targetAccount.AvatarRemoteURL, +				Avatar:    &avatar, +			}) +			if err != nil { +				d.dereferencingAvatarsLock.Unlock() +				return changed, err +			} + +			// store it in our map to indicate it's in process +			d.dereferencingAvatars[targetAccount.ID] = newProcessing +			processingMedia = newProcessing +		} +		d.dereferencingAvatarsLock.Unlock() // UNLOCK HERE + +		// block until loaded if required... +		if blocking { +			if err := lockAndLoad(ctx, d.dereferencingAvatarsLock, processingMedia, d.dereferencingAvatars, targetAccount.ID); err != nil { +				return changed, err +			} +		} else { +			// ...otherwise do it async +			go func() { +				dlCtx, done := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute)) +				if err := lockAndLoad(dlCtx, d.dereferencingAvatarsLock, processingMedia, d.dereferencingAvatars, targetAccount.ID); err != nil { +					logrus.Errorf("fetchRemoteAccountMedia: error during async lock and load of avatar: %s", err) +				} +				done() +			}() +		} + +		targetAccount.AvatarMediaAttachmentID = processingMedia.AttachmentID() +		changed = true  	}  	if targetAccount.HeaderRemoteURL != "" && (targetAccount.HeaderMediaAttachmentID == "" || refresh) { -		a, err := d.mediaHandler.ProcessRemoteHeaderOrAvatar(ctx, t, >smodel.MediaAttachment{ -			RemoteURL: targetAccount.HeaderRemoteURL, -			Header:    true, -		}, targetAccount.ID) -		if err != nil { -			return fmt.Errorf("error processing header for user: %s", err) +		var processingMedia *media.ProcessingMedia + +		d.dereferencingHeadersLock.Lock() // LOCK HERE +		// first check if we're already processing this media +		if alreadyProcessing, ok := d.dereferencingHeaders[targetAccount.ID]; ok { +			// we're already on it, no worries +			processingMedia = alreadyProcessing  		} -		targetAccount.HeaderMediaAttachmentID = a.ID + +		if processingMedia == nil { +			// we're not already processing it so start now +			headerIRI, err := url.Parse(targetAccount.HeaderRemoteURL) +			if err != nil { +				d.dereferencingAvatarsLock.Unlock() +				return changed, err +			} + +			data := func(innerCtx context.Context) (io.Reader, int, error) { +				return t.DereferenceMedia(innerCtx, headerIRI) +			} + +			header := true +			newProcessing, err := d.mediaManager.ProcessMedia(ctx, data, targetAccount.ID, &media.AdditionalMediaInfo{ +				RemoteURL: &targetAccount.HeaderRemoteURL, +				Header:    &header, +			}) +			if err != nil { +				d.dereferencingAvatarsLock.Unlock() +				return changed, err +			} + +			// store it in our map to indicate it's in process +			d.dereferencingHeaders[targetAccount.ID] = newProcessing +			processingMedia = newProcessing +		} +		d.dereferencingHeadersLock.Unlock() // UNLOCK HERE + +		// block until loaded if required... +		if blocking { +			if err := lockAndLoad(ctx, d.dereferencingHeadersLock, processingMedia, d.dereferencingHeaders, targetAccount.ID); err != nil { +				return changed, err +			} +		} else { +			// ...otherwise do it async +			go func() { +				dlCtx, done := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute)) +				if err := lockAndLoad(dlCtx, d.dereferencingHeadersLock, processingMedia, d.dereferencingHeaders, targetAccount.ID); err != nil { +					logrus.Errorf("fetchRemoteAccountMedia: error during async lock and load of header: %s", err) +				} +				done() +			}() +		} + +		targetAccount.HeaderMediaAttachmentID = processingMedia.AttachmentID() +		changed = true  	} -	return nil + +	return changed, nil +} + +func lockAndLoad(ctx context.Context, lock *sync.Mutex, processing *media.ProcessingMedia, processingMap map[string]*media.ProcessingMedia, accountID string) error { +	// whatever happens, remove the in-process media from the map +	defer func() { +		lock.Lock() +		delete(processingMap, accountID) +		lock.Unlock() +	}() + +	// try and load it +	_, err := processing.LoadAttachment(ctx) +	return err  } diff --git a/internal/federation/dereferencing/account_test.go b/internal/federation/dereferencing/account_test.go index 593ad341c..cb6f9588c 100644 --- a/internal/federation/dereferencing/account_test.go +++ b/internal/federation/dereferencing/account_test.go @@ -35,11 +35,10 @@ func (suite *AccountTestSuite) TestDereferenceGroup() {  	fetchingAccount := suite.testAccounts["local_account_1"]  	groupURL := testrig.URLMustParse("https://unknown-instance.com/groups/some_group") -	group, new, err := suite.dereferencer.GetRemoteAccount(context.Background(), fetchingAccount.Username, groupURL, false) +	group, err := suite.dereferencer.GetRemoteAccount(context.Background(), fetchingAccount.Username, groupURL, false, false)  	suite.NoError(err)  	suite.NotNil(group)  	suite.NotNil(group) -	suite.True(new)  	// group values should be set  	suite.Equal("https://unknown-instance.com/groups/some_group", group.URI) diff --git a/internal/federation/dereferencing/attachment.go b/internal/federation/dereferencing/attachment.go deleted file mode 100644 index 36ff2734c..000000000 --- a/internal/federation/dereferencing/attachment.go +++ /dev/null @@ -1,104 +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 <http://www.gnu.org/licenses/>. -*/ - -package dereferencing - -import ( -	"context" -	"errors" -	"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 { -		var alreadyExistsError *db.ErrAlreadyExists -		if !errors.As(err, &alreadyExistsError) { -			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.mediaHandler.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/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index d0b653920..855c4baf8 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -33,42 +33,14 @@ import (  // Dereferencer wraps logic and functionality for doing dereferencing of remote accounts, statuses, etc, from federated instances.  type Dereferencer interface { -	GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, refresh bool) (*gtsmodel.Account, bool, error) -	EnrichRemoteAccount(ctx context.Context, username string, account *gtsmodel.Account) (*gtsmodel.Account, error) +	GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, blocking bool, refresh bool) (*gtsmodel.Account, error)  	GetRemoteStatus(ctx context.Context, username string, remoteStatusID *url.URL, refresh, includeParent bool) (*gtsmodel.Status, ap.Statusable, bool, error)  	EnrichRemoteStatus(ctx context.Context, username string, status *gtsmodel.Status, includeParent bool) (*gtsmodel.Status, error)  	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, 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 @@ -77,21 +49,29 @@ type Dereferencer interface {  }  type deref struct { -	db                  db.DB -	typeConverter       typeutils.TypeConverter -	transportController transport.Controller -	mediaHandler        media.Handler -	handshakes          map[string][]*url.URL -	handshakeSync       *sync.Mutex // mutex to lock/unlock when checking or updating the handshakes map +	db                       db.DB +	typeConverter            typeutils.TypeConverter +	transportController      transport.Controller +	mediaManager             media.Manager +	dereferencingAvatars     map[string]*media.ProcessingMedia +	dereferencingAvatarsLock *sync.Mutex +	dereferencingHeaders     map[string]*media.ProcessingMedia +	dereferencingHeadersLock *sync.Mutex +	handshakes               map[string][]*url.URL +	handshakeSync            *sync.Mutex // mutex to lock/unlock when checking or updating the handshakes map  }  // NewDereferencer returns a Dereferencer initialized with the given parameters. -func NewDereferencer(db db.DB, typeConverter typeutils.TypeConverter, transportController transport.Controller, mediaHandler media.Handler) Dereferencer { +func NewDereferencer(db db.DB, typeConverter typeutils.TypeConverter, transportController transport.Controller, mediaManager media.Manager) Dereferencer {  	return &deref{ -		db:                  db, -		typeConverter:       typeConverter, -		transportController: transportController, -		mediaHandler:        mediaHandler, -		handshakeSync:       &sync.Mutex{}, +		db:                       db, +		typeConverter:            typeConverter, +		transportController:      transportController, +		mediaManager:             mediaManager, +		dereferencingAvatars:     make(map[string]*media.ProcessingMedia), +		dereferencingAvatarsLock: &sync.Mutex{}, +		dereferencingHeaders:     make(map[string]*media.ProcessingMedia), +		dereferencingHeadersLock: &sync.Mutex{}, +		handshakeSync:            &sync.Mutex{},  	}  } diff --git a/internal/federation/dereferencing/dereferencer_test.go b/internal/federation/dereferencing/dereferencer_test.go index 569e8e93b..fe66abce4 100644 --- a/internal/federation/dereferencing/dereferencer_test.go +++ b/internal/federation/dereferencing/dereferencer_test.go @@ -64,7 +64,7 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {  	suite.db = testrig.NewTestDB()  	suite.storage = testrig.NewTestStorage() -	suite.dereferencer = dereferencing.NewDereferencer(suite.db, testrig.NewTestTypeConverter(suite.db), suite.mockTransportController(), testrig.NewTestMediaHandler(suite.db, suite.storage)) +	suite.dereferencer = dereferencing.NewDereferencer(suite.db, testrig.NewTestTypeConverter(suite.db), suite.mockTransportController(), testrig.NewTestMediaManager(suite.db, suite.storage))  	testrig.StandardDBSetup(suite.db, nil)  } diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go new file mode 100644 index 000000000..0b19570f2 --- /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 <http://www.gnu.org/licenses/>. +*/ + +package dereferencing + +import ( +	"context" +	"fmt" +	"io" +	"net/url" + +	"github.com/superseriousbusiness/gotosocial/internal/media" +) + +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("GetRemoteMedia: account ID was empty") +	} + +	t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteMedia: error creating transport: %s", err) +	} + +	derefURI, err := url.Parse(remoteURL) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteMedia: error parsing url: %s", err) +	} + +	dataFunc := func(innerCtx context.Context) (io.Reader, int, error) { +		return t.DereferenceMedia(innerCtx, derefURI) +	} + +	processingMedia, err := d.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai) +	if err != nil { +		return nil, fmt.Errorf("GetRemoteMedia: error processing attachment: %s", err) +	} + +	return processingMedia, nil +} diff --git a/internal/federation/dereferencing/attachment_test.go b/internal/federation/dereferencing/media_test.go index d07cf1c6a..26d5c0c49 100644 --- a/internal/federation/dereferencing/attachment_test.go +++ b/internal/federation/dereferencing/media_test.go @@ -20,17 +20,22 @@ package dereferencing_test  import (  	"context" +	"fmt"  	"testing" +	"time"  	"github.com/stretchr/testify/suite"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/media"  )  type AttachmentTestSuite struct {  	DereferencerStandardTestSuite  } -func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() { +func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() { +	ctx := context.Background() +  	fetchingAccount := suite.testAccounts["local_account_1"]  	attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM" @@ -38,19 +43,20 @@ 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." +	attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" + +	media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ +		StatusID:    &attachmentStatus, +		RemoteURL:   &attachmentURL, +		Description: &attachmentDescription, +		Blurhash:    &attachmentBlurhash, +	}) +	suite.NoError(err) -	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) +	// make a blocking call to load the attachment from the in-process media +	attachment, err := media.LoadAttachment(ctx)  	suite.NoError(err) +  	suite.NotNil(attachment)  	suite.Equal(attachmentOwner, attachment.AccountID) @@ -65,7 +71,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) @@ -91,7 +97,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) @@ -101,6 +107,62 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentOK() {  	suite.NotEmpty(dbAttachment.Type)  } +func (suite *AttachmentTestSuite) TestDereferenceAttachmentAsync() { +	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." +	attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" + +	processingMedia, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ +		StatusID:    &attachmentStatus, +		RemoteURL:   &attachmentURL, +		Description: &attachmentDescription, +		Blurhash:    &attachmentBlurhash, +	}) +	suite.NoError(err) +	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) +	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(attachmentBlurhash, 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) +} +  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..cacca91b2 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, @@ -88,7 +89,7 @@ func (d *deref) GetRemoteStatus(ctx context.Context, username string, remoteStat  	}  	// do this so we know we have the remote account of the status in the db -	_, _, err = d.GetRemoteAccount(ctx, username, accountURI, false) +	_, err = d.GetRemoteAccount(ctx, username, accountURI, true, false)  	if err != nil {  		return nil, statusable, new, fmt.Errorf("GetRemoteStatus: couldn't derive status author: %s", err)  	} @@ -331,7 +332,7 @@ func (d *deref) populateStatusMentions(ctx context.Context, status *gtsmodel.Sta  		if targetAccount == nil {  			// we didn't find the account in our database already  			// check if we can get the account remotely (dereference it) -			if a, _, err := d.GetRemoteAccount(ctx, requestingUsername, targetAccountURI, false); err != nil { +			if a, err := d.GetRemoteAccount(ctx, requestingUsername, targetAccountURI, false, false); err != nil {  				errs = append(errs, err.Error())  			} else {  				logrus.Debugf("populateStatusMentions: got target account %s with id %s through GetRemoteAccount", targetAccountURI, a.ID) @@ -393,9 +394,21 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel.  		a.AccountID = status.AccountID  		a.StatusID = status.ID -		attachment, err := d.GetRemoteAttachment(ctx, requestingUsername, a) +		processingMedia, err := d.GetRemoteMedia(ctx, requestingUsername, a.AccountID, a.RemoteURL, &media.AdditionalMediaInfo{ +			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 attachment %s: %s", a.RemoteURL, err) +			logrus.Errorf("populateStatusAttachments: couldn't get remote media %s: %s", a.RemoteURL, err) +			continue +		} + +		attachment, err := processingMedia.LoadAttachment(ctx) +		if err != nil { +			logrus.Errorf("populateStatusAttachments: couldn't load remote attachment %s: %s", a.RemoteURL, err)  			continue  		} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index f5d42a8e3..789959810 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -153,7 +153,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr  		}  	} -	requestingAccount, _, err := f.GetRemoteAccount(ctx, username, publicKeyOwnerURI, false) +	requestingAccount, err := f.GetRemoteAccount(ctx, username, publicKeyOwnerURI, false, false)  	if err != nil {  		return nil, false, fmt.Errorf("couldn't get requesting account %s: %s", publicKeyOwnerURI, err)  	} diff --git a/internal/federation/federator.go b/internal/federation/federator.go index 0a82f12bc..cb63084db 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -57,8 +57,7 @@ type Federator interface {  	DereferenceRemoteThread(ctx context.Context, username string, statusURI *url.URL) error  	DereferenceAnnounce(ctx context.Context, announce *gtsmodel.Status, requestingUsername string) error -	GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, refresh bool) (*gtsmodel.Account, bool, error) -	EnrichRemoteAccount(ctx context.Context, username string, account *gtsmodel.Account) (*gtsmodel.Account, error) +	GetRemoteAccount(ctx context.Context, username string, remoteAccountID *url.URL, blocking bool, refresh bool) (*gtsmodel.Account, error)  	GetRemoteStatus(ctx context.Context, username string, remoteStatusID *url.URL, refresh, includeParent bool) (*gtsmodel.Status, ap.Statusable, bool, error)  	EnrichRemoteStatus(ctx context.Context, username string, status *gtsmodel.Status, includeParent bool) (*gtsmodel.Status, error) @@ -78,13 +77,13 @@ type federator struct {  	typeConverter       typeutils.TypeConverter  	transportController transport.Controller  	dereferencer        dereferencing.Dereferencer -	mediaHandler        media.Handler +	mediaManager        media.Manager  	actor               pub.FederatingActor  }  // NewFederator returns a new federator -func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController transport.Controller, typeConverter typeutils.TypeConverter, mediaHandler media.Handler) Federator { -	dereferencer := dereferencing.NewDereferencer(db, typeConverter, transportController, mediaHandler) +func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController transport.Controller, typeConverter typeutils.TypeConverter, mediaManager media.Manager) Federator { +	dereferencer := dereferencing.NewDereferencer(db, typeConverter, transportController, mediaManager)  	clock := &Clock{}  	f := &federator{ @@ -94,7 +93,7 @@ func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController tr  		typeConverter:       typeConverter,  		transportController: transportController,  		dereferencer:        dereferencer, -		mediaHandler:        mediaHandler, +		mediaManager:        mediaManager,  	}  	actor := newFederatingActor(f, f, federatingDB, clock)  	f.actor = actor diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go index 43f4904a5..6dac76c05 100644 --- a/internal/federation/federator_test.go +++ b/internal/federation/federator_test.go @@ -78,7 +78,7 @@ func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() {  		return nil, nil  	}), suite.db)  	// setup module being tested -	federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.typeConverter, testrig.NewTestMediaHandler(suite.db, suite.storage)) +	federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.typeConverter, testrig.NewTestMediaManager(suite.db, suite.storage))  	// setup request  	ctx := context.Background() @@ -107,7 +107,7 @@ func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() {  	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)  	// now setup module being tested, with the mock transport controller -	federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.typeConverter, testrig.NewTestMediaHandler(suite.db, suite.storage)) +	federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.typeConverter, testrig.NewTestMediaManager(suite.db, suite.storage))  	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil)  	// we need these headers for the request to be validated 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/gtserror/withcode.go b/internal/gtserror/withcode.go index a00cc8503..34889b961 100644 --- a/internal/gtserror/withcode.go +++ b/internal/gtserror/withcode.go @@ -122,3 +122,16 @@ func NewErrorInternalError(original error, helpText ...string) WithCode {  		code:     http.StatusInternalServerError,  	}  } + +// NewErrorConflict returns an ErrorWithCode 409 with the given original error and optional help text. +func NewErrorConflict(original error, helpText ...string) WithCode { +	safe := "conflict" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return withCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusConflict, +	} +} diff --git a/internal/media/handler.go b/internal/media/handler.go deleted file mode 100644 index e6c7369b6..000000000 --- a/internal/media/handler.go +++ /dev/null @@ -1,318 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( -	"context" -	"errors" -	"fmt" -	"net/url" -	"strings" -	"time" - -	"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/transport" -	"github.com/superseriousbusiness/gotosocial/internal/uris" -) - -// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb) -const EmojiMaxBytes = 51200 - -type Size string - -const ( -	SizeSmall    Size = "small"    // SizeSmall is the key for small/thumbnail versions of media -	SizeOriginal Size = "original" // SizeOriginal is the key for original/fullsize versions of media and emoji -	SizeStatic   Size = "static"   // SizeStatic is the key for static (non-animated) versions of emoji -) - -type Type string - -const ( -	TypeAttachment Type = "attachment" // TypeAttachment is the key for media attachments -	TypeHeader     Type = "header"     // TypeHeader is the key for profile header requests -	TypeAvatar     Type = "avatar"     // TypeAvatar is the key for profile avatar requests -	TypeEmoji      Type = "emoji"      // TypeEmoji is the key for emoji type requests -) - -// Handler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs. -type Handler interface { -	// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, -	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, -	// and then returns information to the caller about the new header. -	ProcessHeaderOrAvatar(ctx context.Context, attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error) - -	// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it, -	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, -	// and then returns information to the caller about the attachment. It's the caller's responsibility to put the returned struct -	// in the database. -	ProcessAttachment(ctx context.Context, attachmentBytes []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) - -	// ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new -	// *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct -	// in the database. -	ProcessLocalEmoji(ctx context.Context, emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error) - -	ProcessRemoteHeaderOrAvatar(ctx context.Context, t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) -} - -type mediaHandler struct { -	db      db.DB -	storage *kv.KVStore -} - -// New returns a new handler with the given db and storage -func New(database db.DB, storage *kv.KVStore) Handler { -	return &mediaHandler{ -		db:      database, -		storage: storage, -	} -} - -/* -	INTERFACE FUNCTIONS -*/ - -// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, -// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, -// and then returns information to the caller about the new header. -func (mh *mediaHandler) ProcessHeaderOrAvatar(ctx context.Context, attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error) { -	l := logrus.WithField("func", "SetHeaderForAccountID") - -	if mediaType != TypeHeader && mediaType != TypeAvatar { -		return nil, errors.New("header or avatar not selected") -	} - -	// make sure we have a type we can handle -	contentType, err := parseContentType(attachment) -	if err != nil { -		return nil, err -	} -	if !SupportedImageType(contentType) { -		return nil, fmt.Errorf("%s is not an accepted image type", contentType) -	} - -	if len(attachment) == 0 { -		return nil, fmt.Errorf("passed reader was of size 0") -	} -	l.Tracef("read %d bytes of file", len(attachment)) - -	// process it -	ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID, remoteURL) -	if err != nil { -		return nil, fmt.Errorf("error processing %s: %s", mediaType, err) -	} - -	// set it in the database -	if err := mh.db.SetAccountHeaderOrAvatar(ctx, ma, accountID); err != nil { -		return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err) -	} - -	return ma, nil -} - -// ProcessAttachment takes a new attachment and the owning account, checks it out, removes exif data from it, -// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, -// and then returns information to the caller about the attachment. -func (mh *mediaHandler) ProcessAttachment(ctx context.Context, attachmentBytes []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { -	contentType, err := parseContentType(attachmentBytes) -	if err != nil { -		return nil, err -	} - -	minAttachment.File.ContentType = contentType - -	mainType := strings.Split(contentType, "/")[0] -	switch mainType { -	// case MIMEVideo: -	// 	if !SupportedVideoType(contentType) { -	// 		return nil, fmt.Errorf("video type %s not supported", contentType) -	// 	} -	// 	if len(attachment) == 0 { -	// 		return nil, errors.New("video was of size 0") -	// 	} -	// 	return mh.processVideoAttachment(attachment, accountID, contentType, remoteURL) -	case MIMEImage: -		if !SupportedImageType(contentType) { -			return nil, fmt.Errorf("image type %s not supported", contentType) -		} -		if len(attachmentBytes) == 0 { -			return nil, errors.New("image was of size 0") -		} -		return mh.processImageAttachment(attachmentBytes, minAttachment) -	default: -		break -	} -	return nil, fmt.Errorf("content type %s not (yet) supported", contentType) -} - -// ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new -// *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct -// in the database. -func (mh *mediaHandler) ProcessLocalEmoji(ctx context.Context, emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error) { -	var clean []byte -	var err error -	var original *imageAndMeta -	var static *imageAndMeta - -	// check content type of the submitted emoji and make sure it's supported by us -	contentType, err := parseContentType(emojiBytes) -	if err != nil { -		return nil, err -	} -	if !supportedEmojiType(contentType) { -		return nil, fmt.Errorf("content type %s not supported for emojis", contentType) -	} - -	if len(emojiBytes) == 0 { -		return nil, errors.New("emoji was of size 0") -	} -	if len(emojiBytes) > EmojiMaxBytes { -		return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes) -	} - -	// clean any exif data from png but leave gifs alone -	switch contentType { -	case MIMEPng: -		if clean, err = purgeExif(emojiBytes); err != nil { -			return nil, fmt.Errorf("error cleaning exif data: %s", err) -		} -	case MIMEGif: -		clean = emojiBytes -	default: -		return nil, errors.New("media type unrecognized") -	} - -	// unlike with other attachments we don't need to derive anything here because we don't care about the width/height etc -	original = &imageAndMeta{ -		image: clean, -	} - -	static, err = deriveStaticEmoji(clean, contentType) -	if err != nil { -		return nil, fmt.Errorf("error deriving static emoji: %s", err) -	} - -	// since emoji aren't 'owned' by an account, but we still want to use the same pattern for serving them through the filserver, -	// (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created -	// with the same username as the instance hostname, which doesn't belong to any particular user. -	instanceAccount, err := mh.db.GetInstanceAccount(ctx, "") -	if err != nil { -		return nil, fmt.Errorf("error fetching instance account: %s", err) -	} - -	// the file extension (either png or gif) -	extension := strings.Split(contentType, "/")[1] - -	// generate a ulid for the new emoji -	newEmojiID, err := id.NewRandomULID() -	if err != nil { -		return nil, err -	} - -	// activitypub uri for the emoji -- unrelated to actually serving the image -	// will be something like https://example.org/emoji/01FPSVBK3H8N7V8XK6KGSQ86EC -	emojiURI := uris.GenerateURIForEmoji(newEmojiID) - -	// serve url and storage path for the original emoji -- can be png or gif -	emojiURL := uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeOriginal), newEmojiID, extension) -	emojiPath := fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeOriginal, newEmojiID, extension) - -	// serve url and storage path for the static version -- will always be png -	emojiStaticURL := uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), newEmojiID, "png") -	emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s.png", instanceAccount.ID, TypeEmoji, SizeStatic, newEmojiID) - -	// Store the original emoji -	if err := mh.storage.Put(emojiPath, original.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	// Store the static emoji -	if err := mh.storage.Put(emojiStaticPath, static.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	// and finally return the new emoji data to the caller -- it's up to them what to do with it -	e := >smodel.Emoji{ -		ID:                     newEmojiID, -		Shortcode:              shortcode, -		Domain:                 "", // empty because this is a local emoji -		CreatedAt:              time.Now(), -		UpdatedAt:              time.Now(), -		ImageRemoteURL:         "", // empty because this is a local emoji -		ImageStaticRemoteURL:   "", // empty because this is a local emoji -		ImageURL:               emojiURL, -		ImageStaticURL:         emojiStaticURL, -		ImagePath:              emojiPath, -		ImageStaticPath:        emojiStaticPath, -		ImageContentType:       contentType, -		ImageStaticContentType: MIMEPng, // static version will always be a png -		ImageFileSize:          len(original.image), -		ImageStaticFileSize:    len(static.image), -		ImageUpdatedAt:         time.Now(), -		Disabled:               false, -		URI:                    emojiURI, -		VisibleInPicker:        true, -		CategoryID:             "", // empty because this is a new emoji -- no category yet -	} -	return e, nil -} - -func (mh *mediaHandler) ProcessRemoteHeaderOrAvatar(ctx context.Context, t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) { -	if !currentAttachment.Header && !currentAttachment.Avatar { -		return nil, errors.New("provided attachment was set to neither header nor avatar") -	} - -	if currentAttachment.Header && currentAttachment.Avatar { -		return nil, errors.New("provided attachment was set to both header and avatar") -	} - -	var headerOrAvi Type -	if currentAttachment.Header { -		headerOrAvi = TypeHeader -	} else if currentAttachment.Avatar { -		headerOrAvi = TypeAvatar -	} - -	if currentAttachment.RemoteURL == "" { -		return nil, errors.New("no remote URL on media attachment to dereference") -	} -	remoteIRI, err := url.Parse(currentAttachment.RemoteURL) -	if err != nil { -		return nil, fmt.Errorf("error parsing attachment url %s: %s", currentAttachment.RemoteURL, err) -	} - -	// for content type, we assume we don't know what to expect... -	expectedContentType := "*/*" -	if currentAttachment.File.ContentType != "" { -		// ... and then narrow it down if we do -		expectedContentType = currentAttachment.File.ContentType -	} - -	attachmentBytes, err := t.DereferenceMedia(ctx, remoteIRI, expectedContentType) -	if err != nil { -		return nil, fmt.Errorf("dereferencing remote media with url %s: %s", remoteIRI.String(), err) -	} - -	return mh.ProcessHeaderOrAvatar(ctx, attachmentBytes, accountID, headerOrAvi, currentAttachment.RemoteURL) -} diff --git a/internal/media/image.go b/internal/media/image.go new file mode 100644 index 000000000..e5390cee5 --- /dev/null +++ b/internal/media/image.go @@ -0,0 +1,198 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"bytes" +	"errors" +	"fmt" +	"image" +	"image/gif" +	"image/jpeg" +	"image/png" +	"io" + +	"github.com/buckket/go-blurhash" +	"github.com/nfnt/resize" +) + +const ( +	thumbnailMaxWidth  = 512 +	thumbnailMaxHeight = 512 +) + +type imageMeta struct { +	width    int +	height   int +	size     int +	aspect   float64 +	blurhash string // defined only for calls to deriveThumbnail if createBlurhash is true +	small    []byte // defined only for calls to deriveStaticEmoji or deriveThumbnail +} + +func decodeGif(r io.Reader) (*imageMeta, error) { +	gif, err := gif.DecodeAll(r) +	if err != nil { +		return nil, err +	} + +	// use the first frame to get the static characteristics +	width := gif.Config.Width +	height := gif.Config.Height +	size := width * height +	aspect := float64(width) / float64(height) + +	return &imageMeta{ +		width:  width, +		height: height, +		size:   size, +		aspect: aspect, +	}, nil +} + +func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { +	var i image.Image +	var err error + +	switch contentType { +	case mimeImageJpeg: +		i, err = jpeg.Decode(r) +	case mimeImagePng: +		i, err = png.Decode(r) +	default: +		err = fmt.Errorf("content type %s not recognised", contentType) +	} + +	if err != nil { +		return nil, err +	} + +	if i == nil { +		return nil, errors.New("processed image was nil") +	} + +	width := i.Bounds().Size().X +	height := i.Bounds().Size().Y +	size := width * height +	aspect := float64(width) / float64(height) + +	return &imageMeta{ +		width:  width, +		height: height, +		size:   size, +		aspect: aspect, +	}, nil +} + +// deriveThumbnail returns a byte slice and metadata for a thumbnail +// of a given jpeg, png, or gif, or an error if something goes wrong. +// +// 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(r io.Reader, contentType string, createBlurhash bool) (*imageMeta, error) { +	var i image.Image +	var err error + +	switch contentType { +	case mimeImageJpeg: +		i, err = jpeg.Decode(r) +	case mimeImagePng: +		i, err = png.Decode(r) +	case mimeImageGif: +		i, err = gif.Decode(r) +	default: +		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) +	width := thumb.Bounds().Size().X +	height := thumb.Bounds().Size().Y +	size := width * height +	aspect := float64(width) / float64(height) + +	im := &imageMeta{ +		width:  width, +		height: height, +		size:   size, +		aspect: aspect, +	} + +	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 { +			return nil, err +		} +		im.blurhash = bh +	} + +	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 +	} +	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(r io.Reader, contentType string) (*imageMeta, error) { +	var i image.Image +	var err error + +	switch contentType { +	case mimeImagePng: +		i, err = png.Decode(r) +		if err != nil { +			return nil, err +		} +	case mimeImageGif: +		i, err = gif.Decode(r) +		if err != nil { +			return nil, err +		} +	default: +		return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) +	} + +	out := &bytes.Buffer{} +	if err := png.Encode(out, i); err != nil { +		return nil, err +	} +	return &imageMeta{ +		small: out.Bytes(), +	}, nil +} diff --git a/internal/media/manager.go b/internal/media/manager.go new file mode 100644 index 000000000..7f626271a --- /dev/null +++ b/internal/media/manager.go @@ -0,0 +1,176 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"context" +	"errors" +	"runtime" + +	"codeberg.org/gruf/go-runners" +	"codeberg.org/gruf/go-store/kv" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/db" +) + +// 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 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 DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) +	ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri 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. +	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 +	numWorkers int +	queueSize  int +} + +// 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 +	} +	queueSize := numWorkers * 10 + +	m := &manager{ +		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 +} + +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 +	} + +	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()) + +	return processingMedia, nil +} + +func (m *manager) ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) { +	processingEmoji, err := m.preProcessEmoji(ctx, data, shortcode, id, uri, ai) +	if err != nil { +		return nil, err +	} + +	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 { +	return m.numWorkers +} + +func (m *manager) QueueSize() int { +	return m.queueSize +} + +func (m *manager) JobsQueued() int { +	return m.pool.Queue() +} + +func (m *manager) ActiveWorkers() int { +	return m.pool.Workers() +} + +func (m *manager) Stop() error { +	logrus.Info("stopping media manager worker pool") + +	stopped := m.pool.Stop() +	if !stopped { +		return errors.New("could not stop media manager worker pool") +	} +	return nil +} diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go new file mode 100644 index 000000000..a9419754c --- /dev/null +++ b/internal/media/manager_test.go @@ -0,0 +1,363 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media_test + +import ( +	"bytes" +	"context" +	"fmt" +	"io" +	"os" +	"path" +	"testing" +	"time" + +	"codeberg.org/gruf/go-store/kv" +	"codeberg.org/gruf/go-store/storage" +	"github.com/stretchr/testify/suite" +	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20211113114307_init" +	"github.com/superseriousbusiness/gotosocial/internal/media" +) + +type ManagerTestSuite struct { +	MediaStandardTestSuite +} + +func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { +	ctx := context.Background() + +	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), len(b), nil +	} + +	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + +	// process the media with no additional info provided +	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.LoadAttachment(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(269739, attachment.File.FileSize) +	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 *ManagerTestSuite) TestSimpleJpegProcessAsync() { +	ctx := context.Background() + +	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), len(b), nil +	} + +	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + +	// process the media with no additional info provided +	processingMedia, err := suite.manager.ProcessMedia(ctx, data, 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(269739, attachment.File.FileSize) +	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 *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() + +	b, err := os.ReadFile("./test/test-jpeg.jpg") +	if err != nil { +		panic(err) +	} + +	data := func(_ context.Context) (io.Reader, int, error) { +		// load bytes from a test image +		return bytes.NewReader(b), len(b), nil +	} + +	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + +	spam := 50 +	inProcess := []*media.ProcessingMedia{} +	for i := 0; i < spam; i++ { +		// process the media with no additional info provided +		processingMedia, err := suite.manager.ProcessMedia(ctx, data, 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.LoadAttachment(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(269739, attachment.File.FileSize) +		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 *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { +	ctx := context.Background() + +	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), len(b), nil +	} + +	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + +	temp := fmt.Sprintf("%s/gotosocial-test", os.TempDir()) +	defer os.RemoveAll(temp) + +	diskStorage, err := kv.OpenFile(temp, &storage.DiskConfig{ +		LockFile: path.Join(temp, "store.lock"), +	}) +	if err != nil { +		panic(err) +	} + +	diskManager, err := media.NewManager(suite.db, diskStorage) +	if err != nil { +		panic(err) +	} +	suite.manager = diskManager + +	// process the media with no additional info provided +	processingMedia, err := diskManager.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.LoadAttachment(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(269739, attachment.File.FileSize) +	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 := diskStorage.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 := diskStorage.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 TestManagerTestSuite(t *testing.T) { +	suite.Run(t, &ManagerTestSuite{}) +} 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 <http://www.gnu.org/licenses/>. +*/ + +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/processicon.go b/internal/media/processicon.go deleted file mode 100644 index 66cf1f999..000000000 --- a/internal/media/processicon.go +++ /dev/null @@ -1,143 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( -	"errors" -	"fmt" -	"strings" -	"time" - -	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/id" -	"github.com/superseriousbusiness/gotosocial/internal/uris" -) - -func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string, remoteURL string) (*gtsmodel.MediaAttachment, error) { -	var isHeader bool -	var isAvatar bool - -	switch mediaType { -	case TypeHeader: -		isHeader = true -	case TypeAvatar: -		isAvatar = true -	default: -		return nil, errors.New("header or avatar not selected") -	} - -	var clean []byte -	var err error - -	var original *imageAndMeta -	switch contentType { -	case MIMEJpeg: -		if clean, err = purgeExif(imageBytes); err != nil { -			return nil, fmt.Errorf("error cleaning exif data: %s", err) -		} -		original, err = deriveImage(clean, contentType) -	case MIMEPng: -		if clean, err = purgeExif(imageBytes); err != nil { -			return nil, fmt.Errorf("error cleaning exif data: %s", err) -		} -		original, err = deriveImage(clean, contentType) -	case MIMEGif: -		clean = imageBytes -		original, err = deriveGif(clean, contentType) -	default: -		return nil, errors.New("media type unrecognized") -	} - -	if err != nil { -		return nil, fmt.Errorf("error parsing image: %s", err) -	} - -	small, err := deriveThumbnail(clean, contentType, 256, 256) -	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 -	extension := strings.Split(contentType, "/")[1] -	newMediaID, err := id.NewRandomULID() -	if err != nil { -		return nil, err -	} - -	originalURL := uris.GenerateURIForAttachment(accountID, string(mediaType), string(SizeOriginal), newMediaID, extension) -	smallURL := uris.GenerateURIForAttachment(accountID, string(mediaType), string(SizeSmall), newMediaID, extension) -	// we store the original... -	originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", accountID, mediaType, SizeOriginal, newMediaID, extension) -	if err := mh.storage.Put(originalPath, original.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	// and a thumbnail... -	smallPath := fmt.Sprintf("%s/%s/%s/%s.%s", accountID, mediaType, SizeSmall, newMediaID, extension) -	if err := mh.storage.Put(smallPath, small.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	ma := >smodel.MediaAttachment{ -		ID:        newMediaID, -		StatusID:  "", -		URL:       originalURL, -		RemoteURL: remoteURL, -		CreatedAt: time.Now(), -		UpdatedAt: time.Now(), -		Type:      gtsmodel.FileTypeImage, -		FileMeta: gtsmodel.FileMeta{ -			Original: gtsmodel.Original{ -				Width:  original.width, -				Height: original.height, -				Size:   original.size, -				Aspect: original.aspect, -			}, -			Small: gtsmodel.Small{ -				Width:  small.width, -				Height: small.height, -				Size:   small.size, -				Aspect: small.aspect, -			}, -		}, -		AccountID:         accountID, -		Description:       "", -		ScheduledStatusID: "", -		Blurhash:          small.blurhash, -		Processing:        2, -		File: gtsmodel.File{ -			Path:        originalPath, -			ContentType: contentType, -			FileSize:    len(original.image), -			UpdatedAt:   time.Now(), -		}, -		Thumbnail: gtsmodel.Thumbnail{ -			Path:        smallPath, -			ContentType: contentType, -			FileSize:    len(small.image), -			UpdatedAt:   time.Now(), -			URL:         smallURL, -			RemoteURL:   "", -		}, -		Avatar: isAvatar, -		Header: isHeader, -	} - -	return ma, nil -} diff --git a/internal/media/processimage.go b/internal/media/processimage.go deleted file mode 100644 index ca92c0660..000000000 --- a/internal/media/processimage.go +++ /dev/null @@ -1,133 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( -	"errors" -	"fmt" -	"strings" -	"time" - -	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/id" -	"github.com/superseriousbusiness/gotosocial/internal/uris" -) - -func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { -	var clean []byte -	var err error -	var original *imageAndMeta -	var small *imageAndMeta - -	contentType := minAttachment.File.ContentType - -	switch contentType { -	case MIMEJpeg, MIMEPng: -		if clean, err = purgeExif(data); err != nil { -			return nil, fmt.Errorf("error cleaning exif data: %s", err) -		} -		original, err = deriveImage(clean, contentType) -		if err != nil { -			return nil, fmt.Errorf("error parsing image: %s", err) -		} -	case MIMEGif: -		clean = data -		original, err = deriveGif(clean, contentType) -		if err != nil { -			return nil, fmt.Errorf("error parsing gif: %s", err) -		} -	default: -		return nil, errors.New("media type unrecognized") -	} - -	small, err = deriveThumbnail(clean, contentType, 512, 512) -	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 -	extension := strings.Split(contentType, "/")[1] -	newMediaID, err := id.NewRandomULID() -	if err != nil { -		return nil, err -	} - -	originalURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeOriginal), newMediaID, extension) -	smallURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeSmall), newMediaID, "jpeg") // all thumbnails/smalls are encoded as jpeg - -	// we store the original... -	originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", minAttachment.AccountID, TypeAttachment, SizeOriginal, newMediaID, extension) -	if err := mh.storage.Put(originalPath, original.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	// and a thumbnail... -	smallPath := fmt.Sprintf("%s/%s/%s/%s.jpeg", minAttachment.AccountID, TypeAttachment, SizeSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg -	if err := mh.storage.Put(smallPath, small.image); err != nil { -		return nil, fmt.Errorf("storage error: %s", err) -	} - -	minAttachment.FileMeta.Original = gtsmodel.Original{ -		Width:  original.width, -		Height: original.height, -		Size:   original.size, -		Aspect: original.aspect, -	} - -	minAttachment.FileMeta.Small = gtsmodel.Small{ -		Width:  small.width, -		Height: small.height, -		Size:   small.size, -		Aspect: small.aspect, -	} - -	attachment := >smodel.MediaAttachment{ -		ID:                newMediaID, -		StatusID:          minAttachment.StatusID, -		URL:               originalURL, -		RemoteURL:         minAttachment.RemoteURL, -		CreatedAt:         minAttachment.CreatedAt, -		UpdatedAt:         minAttachment.UpdatedAt, -		Type:              gtsmodel.FileTypeImage, -		FileMeta:          minAttachment.FileMeta, -		AccountID:         minAttachment.AccountID, -		Description:       minAttachment.Description, -		ScheduledStatusID: minAttachment.ScheduledStatusID, -		Blurhash:          small.blurhash, -		Processing:        2, -		File: gtsmodel.File{ -			Path:        originalPath, -			ContentType: contentType, -			FileSize:    len(original.image), -			UpdatedAt:   time.Now(), -		}, -		Thumbnail: gtsmodel.Thumbnail{ -			Path:        smallPath, -			ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg -			FileSize:    len(small.image), -			UpdatedAt:   time.Now(), -			URL:         smallURL, -			RemoteURL:   minAttachment.Thumbnail.RemoteURL, -		}, -		Avatar: minAttachment.Avatar, -		Header: minAttachment.Header, -	} - -	return attachment, nil -} diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go new file mode 100644 index 000000000..741854b9b --- /dev/null +++ b/internal/media/processingemoji.go @@ -0,0 +1,290 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"bytes" +	"context" +	"fmt" +	"io" +	"strings" +	"sync" +	"sync/atomic" +	"time" + +	"codeberg.org/gruf/go-store/kv" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"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 + +	// id of this instance's account -- pinned for convenience here so we only need to fetch it once +	instanceAccountID string + +	/* +		below fields should be set on newly created media; +		emoji will be updated incrementally as media goes through processing +	*/ + +	emoji *gtsmodel.Emoji +	data  DataFunc +	read  bool // bool indicating that data function has been triggered already + +	/* +		below fields represent the processing state of the static of the emoji +	*/ +	staticState int32 + +	/* +		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 + +	// track whether this emoji has already been put in the databse +	insertedInDB bool +} + +// 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) { +	p.mu.Lock() +	defer p.mu.Unlock() + +	if err := p.store(ctx); err != nil { +		return nil, err +	} + +	if err := p.loadStatic(ctx); err != nil { +		return nil, err +	} + +	// store the result in the database before returning it +	if !p.insertedInDB { +		if err := p.database.Put(ctx, p.emoji); err != nil { +			return nil, err +		} +		p.insertedInDB = true +	} + +	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 atomic.LoadInt32(&p.staticState) == int32(complete) +} + +func (p *ProcessingEmoji) loadStatic(ctx context.Context) error { +	staticState := atomic.LoadInt32(&p.staticState) +	switch processState(staticState) { +	case received: +		// stream the original file out of storage... +		stored, err := p.storage.GetStream(p.emoji.ImagePath) +		if err != nil { +			p.err = fmt.Errorf("loadStatic: error fetching file from storage: %s", err) +			atomic.StoreInt32(&p.staticState, int32(errored)) +			return p.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) +			atomic.StoreInt32(&p.staticState, int32(errored)) +			return p.err +		} + +		if err := stored.Close(); err != nil { +			p.err = fmt.Errorf("loadStatic: error closing stored full size: %s", err) +			atomic.StoreInt32(&p.staticState, int32(errored)) +			return 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) +			atomic.StoreInt32(&p.staticState, int32(errored)) +			return p.err +		} + +		p.emoji.ImageStaticFileSize = len(static.small) + +		// we're done processing the static version of the emoji! +		atomic.StoreInt32(&p.staticState, int32(complete)) +		fallthrough +	case complete: +		return nil +	case errored: +		return p.err +	} + +	return fmt.Errorf("static processing status %d unknown", p.staticState) +} + +// 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.read { +		return nil +	} + +	// execute the data function to get the reader out of it +	reader, fileSize, err := p.data(ctx) +	if err != nil { +		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) +	} + +	// 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("store: error parsing content type: %s", err) +	} + +	// bail if this is a type we can't process +	if !supportedEmoji(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' + +	// 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.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) + +	// 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) +	} + +	// 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 +} + +func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri 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) +	} + +	// 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:                    uri, +		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 { +			emoji.CreatedAt = *ai.CreatedAt +		} + +		if ai.Domain != nil { +			emoji.Domain = *ai.Domain +		} + +		if ai.ImageRemoteURL != nil { +			emoji.ImageRemoteURL = *ai.ImageRemoteURL +		} + +		if ai.ImageStaticRemoteURL != nil { +			emoji.ImageStaticRemoteURL = *ai.ImageStaticRemoteURL +		} + +		if ai.Disabled != nil { +			emoji.Disabled = *ai.Disabled +		} + +		if ai.VisibleInPicker != nil { +			emoji.VisibleInPicker = *ai.VisibleInPicker +		} + +		if ai.CategoryID != nil { +			emoji.CategoryID = *ai.CategoryID +		} +	} + +	processingEmoji := &ProcessingEmoji{ +		instanceAccountID: instanceAccount.ID, +		emoji:             emoji, +		data:              data, +		staticState:       int32(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..0f47ee4e6 --- /dev/null +++ b/internal/media/processingmedia.go @@ -0,0 +1,413 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"bytes" +	"context" +	"fmt" +	"io" +	"strings" +	"sync" +	"sync/atomic" +	"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" +	"github.com/superseriousbusiness/gotosocial/internal/uris" +) + +// 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 +	read       bool // bool indicating that data function has been triggered already + +	thumbState    int32 // the processing state of the media thumbnail +	fullSizeState int32 // the processing state of the full-sized media + +	/* +		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 + +	// track whether this media has already been put in the databse +	insertedInDB bool +} + +// 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) { +	p.mu.Lock() +	defer p.mu.Unlock() + +	if err := p.store(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 +	} + +	// store the result in the database before returning it +	if !p.insertedInDB { +		if err := p.database.Put(ctx, p.attachment); err != nil { +			return nil, err +		} +		p.insertedInDB = true +	} + +	return p.attachment, 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 atomic.LoadInt32(&p.thumbState) == int32(complete) && atomic.LoadInt32(&p.fullSizeState) == int32(complete) +} + +func (p *ProcessingMedia) loadThumb(ctx context.Context) error { +	thumbState := atomic.LoadInt32(&p.thumbState) +	switch processState(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 +		} + +		// 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) +			atomic.StoreInt32(&p.thumbState, int32(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("loadThumb: error deriving thumbnail: %s", err) +			atomic.StoreInt32(&p.thumbState, int32(errored)) +			return p.err +		} + +		if err := stored.Close(); err != nil { +			p.err = fmt.Errorf("loadThumb: error closing stored full size: %s", err) +			atomic.StoreInt32(&p.thumbState, int32(errored)) +			return p.err +		} + +		// put the thumbnail in storage +		if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.small); err != nil { +			p.err = fmt.Errorf("loadThumb: error storing thumbnail: %s", err) +			atomic.StoreInt32(&p.thumbState, int32(errored)) +			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.small) + +		// we're done processing the thumbnail! +		atomic.StoreInt32(&p.thumbState, int32(complete)) +		fallthrough +	case complete: +		return nil +	case errored: +		return p.err +	} + +	return fmt.Errorf("loadThumb: thumbnail processing status %d unknown", p.thumbState) +} + +func (p *ProcessingMedia) loadFullSize(ctx context.Context) error { +	fullSizeState := atomic.LoadInt32(&p.fullSizeState) +	switch processState(fullSizeState) { +	case received: +		var err error +		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) +			atomic.StoreInt32(&p.fullSizeState, int32(errored)) +			return p.err +		} + +		// decode the image +		ct := p.attachment.File.ContentType +		switch ct { +		case mimeImageJpeg, mimeImagePng: +			decoded, err = decodeImage(stored, ct) +		case mimeImageGif: +			decoded, err = decodeGif(stored) +		default: +			err = fmt.Errorf("loadFullSize: content type %s not a processible image type", ct) +		} + +		if err != nil { +			p.err = err +			atomic.StoreInt32(&p.fullSizeState, int32(errored)) +			return p.err +		} + +		if err := stored.Close(); err != nil { +			p.err = fmt.Errorf("loadFullSize: error closing stored full size: %s", err) +			atomic.StoreInt32(&p.fullSizeState, int32(errored)) +			return 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.UpdatedAt = time.Now() +		p.attachment.Processing = gtsmodel.ProcessingStatusProcessed + +		// we're done processing the full-size image +		atomic.StoreInt32(&p.fullSizeState, int32(complete)) +		fallthrough +	case complete: +		return nil +	case errored: +		return p.err +	} + +	return fmt.Errorf("loadFullSize: full size processing status %d unknown", p.fullSizeState) +} + +// 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.read { +		return nil +	} + +	// execute the data function to get the reader out of it +	reader, fileSize, err := p.data(ctx) +	if err != nil { +		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) +	} + +	// 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("store: error parsing content type: %s", err) +	} + +	// bail if this is a type we can't process +	if !supportedImage(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("store: content type %s was not valid", contentType) +	} +	extension := split[1] // something like 'jpeg' + +	// 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 +		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 +	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, clean); 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 +} + +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:    int32(received), +		fullSizeState: int32(received), +		database:      m.db, +		storage:       m.storage, +	} + +	return processingMedia, nil +} diff --git a/internal/media/processvideo.go b/internal/media/processvideo.go deleted file mode 100644 index d0d11f779..000000000 --- a/internal/media/processvideo.go +++ /dev/null @@ -1,23 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -// func (mh *mediaHandler) processVideoAttachment(data []byte, accountID string, contentType string, remoteURL string) (*gtsmodel.MediaAttachment, error) { -// 	return nil, 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.jpgBinary files differ deleted file mode 100644 index 6b6ba472e..000000000 --- a/internal/media/test/test-jpeg-blurhash.jpg +++ /dev/null diff --git a/internal/media/test/test-with-exif.jpg b/internal/media/test/test-with-exif.jpgBinary files differ deleted file mode 100644 index de56cd654..000000000 --- a/internal/media/test/test-with-exif.jpg +++ /dev/null diff --git a/internal/media/test/test-without-exif.jpg b/internal/media/test/test-without-exif.jpgBinary files differ deleted file mode 100644 index 274188ee7..000000000 --- a/internal/media/test/test-without-exif.jpg +++ /dev/null diff --git a/internal/media/types.go b/internal/media/types.go new file mode 100644 index 000000000..a6b38b467 --- /dev/null +++ b/internal/media/types.go @@ -0,0 +1,121 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"context" +	"io" +	"time" +) + +// maxFileHeaderBytes represents the maximum amount of bytes we want +// to examine from the beginning of a file to determine its type. +// +// See: https://en.wikipedia.org/wiki/File_format#File_header +// and https://github.com/h2non/filetype +const maxFileHeaderBytes = 261 + +// mime consts +const ( +	mimeImage = "image" + +	mimeJpeg      = "jpeg" +	mimeImageJpeg = mimeImage + "/" + mimeJpeg + +	mimeGif      = "gif" +	mimeImageGif = mimeImage + "/" + mimeGif + +	mimePng      = "png" +	mimeImagePng = mimeImage + "/" + mimePng +) + +type processState int32 + +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 +) + +// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb) +// const EmojiMaxBytes = 51200 + +type Size string + +const ( +	SizeSmall    Size = "small"    // SizeSmall is the key for small/thumbnail versions of media +	SizeOriginal Size = "original" // SizeOriginal is the key for original/fullsize versions of media and emoji +	SizeStatic   Size = "static"   // SizeStatic is the key for static (non-animated) versions of emoji +) + +type Type string + +const ( +	TypeAttachment Type = "attachment" // TypeAttachment is the key for media attachments +	TypeHeader     Type = "header"     // TypeHeader is the key for profile header requests +	TypeAvatar     Type = "avatar"     // TypeAvatar is the key for profile avatar requests +	TypeEmoji      Type = "emoji"      // TypeEmoji is the key for emoji type requests +) + +// AdditionalMediaInfo represents additional information that should be added to an attachment +// when processing a piece of media. +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 "". +	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 +} + +// AdditionalMediaInfo represents additional information +// that should be added to an emoji when processing it. +type AdditionalEmojiInfo struct { +	// Time that this emoji was created; defaults to time.Now(). +	CreatedAt *time.Time +	// Domain the emoji originated from. Blank for this instance's domain. Defaults to "". +	Domain *string +	// URL of this emoji on a remote instance; defaults to "". +	ImageRemoteURL *string +	// URL of the static version of this emoji on a remote instance; defaults to "". +	ImageStaticRemoteURL *string +	// Whether this emoji should be disabled (not shown) on this instance; defaults to false. +	Disabled *bool +	// Whether this emoji should be visible in the instance's emoji picker; defaults to true. +	VisibleInPicker *bool +	// ID of the category this emoji should be placed in; defaults to "". +	CategoryID *string +} + +// DataFunc represents a function used to retrieve the raw bytes of a piece of media. +type DataFunc func(ctx context.Context) (reader io.Reader, fileSize int, err error) diff --git a/internal/media/util.go b/internal/media/util.go index 348136c92..248d5fb19 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -19,50 +19,22 @@  package media  import ( -	"bytes"  	"errors"  	"fmt" -	"image" -	"image/gif" -	"image/jpeg" -	"image/png" -	"github.com/buckket/go-blurhash"  	"github.com/h2non/filetype" -	"github.com/nfnt/resize" -	"github.com/superseriousbusiness/exifremove/pkg/exifremove" -) - -const ( -	// MIMEImage is the mime type for image -	MIMEImage = "image" -	// MIMEJpeg is the jpeg image mime type -	MIMEJpeg = "image/jpeg" -	// MIMEGif is the gif image mime type -	MIMEGif = "image/gif" -	// MIMEPng is the png image mime type -	MIMEPng = "image/png" - -	// MIMEVideo is the mime type for video -	MIMEVideo = "video" -	// MIMEMp4 is the mp4 video mime type -	MIMEMp4 = "video/mp4" -	// MIMEMpeg is the mpeg video mime type -	MIMEMpeg = "video/mpeg" -	// MIMEWebm is the webm video mime type -	MIMEWebm = "video/webm"  )  // 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) { -	head := make([]byte, 261) -	_, err := bytes.NewReader(content).Read(head) -	if 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(head) +	kind, err := filetype.Match(fileHeader)  	if err != nil {  		return "", err  	} @@ -74,13 +46,13 @@ func parseContentType(content []byte) (string, error) {  	return kind.MIME.Value, nil  } -// SupportedImageType checks mime type of an image against a slice of accepted types, +// supportedImage checks mime type of an image against a slice of accepted types,  // and returns True if the mime type is accepted. -func SupportedImageType(mimeType string) bool { +func supportedImage(mimeType string) bool {  	acceptedImageTypes := []string{ -		MIMEJpeg, -		MIMEGif, -		MIMEPng, +		mimeImageJpeg, +		mimeImageGif, +		mimeImagePng,  	}  	for _, accepted := range acceptedImageTypes {  		if mimeType == accepted { @@ -90,27 +62,11 @@ func SupportedImageType(mimeType string) bool {  	return false  } -// SupportedVideoType checks mime type of a video against a slice of accepted types, -// and returns True if the mime type is accepted. -func SupportedVideoType(mimeType string) bool { -	acceptedVideoTypes := []string{ -		MIMEMp4, -		MIMEMpeg, -		MIMEWebm, -	} -	for _, accepted := range acceptedVideoTypes { -		if mimeType == accepted { -			return true -		} -	} -	return false -} - -// supportedEmojiType checks that the content type is image/png -- the only type supported for emoji. -func supportedEmojiType(mimeType string) bool { +// supportedEmoji checks that the content type is image/png or image/gif -- the only types supported for emoji. +func supportedEmoji(mimeType string) bool {  	acceptedEmojiTypes := []string{ -		MIMEGif, -		MIMEPng, +		mimeImageGif, +		mimeImagePng,  	}  	for _, accepted := range acceptedEmojiTypes {  		if mimeType == accepted { @@ -120,179 +76,6 @@ func supportedEmojiType(mimeType string) bool {  	return false  } -// 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(b []byte) ([]byte, error) { -	if len(b) == 0 { -		return nil, errors.New("passed image was not valid") -	} - -	clean, err := exifremove.Remove(b) -	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 -} - -func deriveGif(b []byte, extension string) (*imageAndMeta, error) { -	var g *gif.GIF -	var err error -	switch extension { -	case MIMEGif: -		g, err = gif.DecodeAll(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	default: -		return nil, fmt.Errorf("extension %s not recognised", extension) -	} - -	// use the first frame to get the static characteristics -	width := g.Config.Width -	height := g.Config.Height -	size := width * height -	aspect := float64(width) / float64(height) - -	return &imageAndMeta{ -		image:  b, -		width:  width, -		height: height, -		size:   size, -		aspect: aspect, -	}, nil -} - -func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { -	var i image.Image -	var err error - -	switch contentType { -	case MIMEJpeg: -		i, err = jpeg.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	case MIMEPng: -		i, err = png.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	default: -		return nil, fmt.Errorf("content type %s not recognised", contentType) -	} - -	width := i.Bounds().Size().X -	height := i.Bounds().Size().Y -	size := width * height -	aspect := float64(width) / float64(height) - -	return &imageAndMeta{ -		image:  b, -		width:  width, -		height: height, -		size:   size, -		aspect: aspect, -	}, nil -} - -// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y, -// 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. -func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) { -	var i image.Image -	var err error - -	switch contentType { -	case MIMEJpeg: -		i, err = jpeg.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	case MIMEPng: -		i, err = png.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	case MIMEGif: -		i, err = gif.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	default: -		return nil, fmt.Errorf("content type %s not recognised", contentType) -	} - -	thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor) -	width := thumb.Bounds().Size().X -	height := thumb.Bounds().Size().Y -	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 -	} - -	out := &bytes.Buffer{} -	if err := jpeg.Encode(out, thumb, &jpeg.Options{ -		Quality: 75, -	}); err != nil { -		return nil, err -	} -	return &imageAndMeta{ -		image:    out.Bytes(), -		width:    width, -		height:   height, -		size:     size, -		aspect:   aspect, -		blurhash: bh, -	}, 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) (*imageAndMeta, error) { -	var i image.Image -	var err error - -	switch contentType { -	case MIMEPng: -		i, err = png.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	case MIMEGif: -		i, err = gif.Decode(bytes.NewReader(b)) -		if err != nil { -			return nil, err -		} -	default: -		return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) -	} - -	out := &bytes.Buffer{} -	if err := png.Encode(out, i); err != nil { -		return nil, err -	} -	return &imageAndMeta{ -		image: out.Bytes(), -	}, nil -} - -type imageAndMeta struct { -	image    []byte -	width    int -	height   int -	size     int -	aspect   float64 -	blurhash string -} -  // ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized  func ParseMediaType(s string) (Type, error) {  	switch s { diff --git a/internal/media/util_test.go b/internal/media/util_test.go deleted file mode 100644 index cb299d50e..000000000 --- a/internal/media/util_test.go +++ /dev/null @@ -1,150 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( -	"io/ioutil" -	"testing" - -	"github.com/spf13/viper" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/log" - -	"github.com/stretchr/testify/suite" -) - -type MediaUtilTestSuite struct { -	suite.Suite -} - -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *MediaUtilTestSuite) SetupSuite() { -	// doesn't use testrig.InitTestLog() helper to prevent import cycle -	viper.Set(config.Keys.LogLevel, "trace") -	err := log.Initialize() -	if err != nil { -		panic(err) -	} -} - -func (suite *MediaUtilTestSuite) TearDownSuite() { - -} - -// SetupTest creates a db connection and creates necessary tables before each test -func (suite *MediaUtilTestSuite) SetupTest() { - -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *MediaUtilTestSuite) TearDownTest() { - -} - -/* -	ACTUAL TESTS -*/ - -func (suite *MediaUtilTestSuite) TestParseContentTypeOK() { -	f, err := ioutil.ReadFile("./test/test-jpeg.jpg") -	suite.NoError(err) -	ct, err := parseContentType(f) -	suite.NoError(err) -	suite.Equal("image/jpeg", ct) -} - -func (suite *MediaUtilTestSuite) TestParseContentTypeNotOK() { -	f, err := ioutil.ReadFile("./test/test-corrupted.jpg") -	suite.NoError(err) -	ct, err := parseContentType(f) -	suite.NotNil(err) -	suite.Equal("", ct) -	suite.Equal("filetype unknown", err.Error()) -} - -func (suite *MediaUtilTestSuite) TestRemoveEXIF() { -	// load and validate image -	b, err := ioutil.ReadFile("./test/test-with-exif.jpg") -	suite.NoError(err) - -	// clean it up and validate the clean version -	clean, err := purgeExif(b) -	suite.NoError(err) - -	// compare it to our stored sample -	sampleBytes, err := ioutil.ReadFile("./test/test-without-exif.jpg") -	suite.NoError(err) -	suite.EqualValues(sampleBytes, clean) -} - -func (suite *MediaUtilTestSuite) TestDeriveImageFromJPEG() { -	// load image -	b, err := ioutil.ReadFile("./test/test-jpeg.jpg") -	suite.NoError(err) - -	// clean it up and validate the clean version -	imageAndMeta, err := deriveImage(b, "image/jpeg") -	suite.NoError(err) - -	suite.Equal(1920, imageAndMeta.width) -	suite.Equal(1080, imageAndMeta.height) -	suite.Equal(1.7777777777777777, imageAndMeta.aspect) -	suite.Equal(2073600, imageAndMeta.size) - -	// assert that the final image is what we would expect -	sampleBytes, err := ioutil.ReadFile("./test/test-jpeg-processed.jpg") -	suite.NoError(err) -	suite.EqualValues(sampleBytes, imageAndMeta.image) -} - -func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() { -	// load image -	b, err := ioutil.ReadFile("./test/test-jpeg.jpg") -	suite.NoError(err) - -	// clean it up and validate the clean version -	imageAndMeta, err := deriveThumbnail(b, "image/jpeg", 512, 512) -	suite.NoError(err) - -	suite.Equal(512, imageAndMeta.width) -	suite.Equal(288, imageAndMeta.height) -	suite.Equal(1.7777777777777777, imageAndMeta.aspect) -	suite.Equal(147456, imageAndMeta.size) -	suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", imageAndMeta.blurhash) - -	sampleBytes, err := ioutil.ReadFile("./test/test-jpeg-thumbnail.jpg") -	suite.NoError(err) -	suite.EqualValues(sampleBytes, imageAndMeta.image) -} - -func (suite *MediaUtilTestSuite) TestSupportedImageTypes() { -	ok := SupportedImageType("image/jpeg") -	suite.True(ok) - -	ok = SupportedImageType("image/bmp") -	suite.False(ok) -} - -func TestMediaUtilTestSuite(t *testing.T) { -	suite.Run(t, new(MediaUtilTestSuite)) -} diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index ae005f4f6..b2321f414 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -77,7 +77,7 @@ type Processor interface {  type processor struct {  	tc            typeutils.TypeConverter -	mediaHandler  media.Handler +	mediaManager  media.Manager  	fromClientAPI chan messages.FromClientAPI  	oauthServer   oauth.Server  	filter        visibility.Filter @@ -87,10 +87,10 @@ type processor struct {  }  // New returns a new account processor. -func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, oauthServer oauth.Server, fromClientAPI chan messages.FromClientAPI, federator federation.Federator) Processor { +func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, oauthServer oauth.Server, fromClientAPI chan messages.FromClientAPI, federator federation.Federator) Processor {  	return &processor{  		tc:            tc, -		mediaHandler:  mediaHandler, +		mediaManager:  mediaManager,  		fromClientAPI: fromClientAPI,  		oauthServer:   oauthServer,  		filter:        visibility.NewFilter(db), diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index e4611ba23..5a9382ed6 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -41,7 +41,7 @@ type AccountStandardTestSuite struct {  	db                  db.DB  	tc                  typeutils.TypeConverter  	storage             *kv.KVStore -	mediaHandler        media.Handler +	mediaManager        media.Manager  	oauthServer         oauth.Server  	fromClientAPIChan   chan messages.FromClientAPI  	httpClient          pub.HttpClient @@ -80,15 +80,15 @@ func (suite *AccountStandardTestSuite) SetupTest() {  	suite.db = testrig.NewTestDB()  	suite.tc = testrig.NewTestTypeConverter(suite.db)  	suite.storage = testrig.NewTestStorage() -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) +	suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)  	suite.oauthServer = testrig.NewTestOauthServer(suite.db)  	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.mediaHandler, suite.oauthServer, suite.fromClientAPIChan, suite.federator) +	suite.accountProcessor = account.New(suite.db, suite.tc, suite.mediaManager, suite.oauthServer, suite.fromClientAPIChan, suite.federator)  	testrig.StandardDBSetup(suite.db, nil)  	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")  } diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go index e96040db7..2571d7af1 100644 --- a/internal/processing/account/get.go +++ b/internal/processing/account/get.go @@ -22,6 +22,7 @@ import (  	"context"  	"errors"  	"fmt" +	"net/url"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/db" @@ -56,7 +57,12 @@ func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account  	// last-minute check to make sure we have remote account header/avi cached  	if targetAccount.Domain != "" { -		a, err := p.federator.EnrichRemoteAccount(ctx, requestingAccount.Username, targetAccount) +		targetAccountURI, err := url.Parse(targetAccount.URI) +		if err != nil { +			return nil, fmt.Errorf("error parsing url %s: %s", targetAccount.URI, err) +		} + +		a, err := p.federator.GetRemoteAccount(ctx, requestingAccount.Username, targetAccountURI, true, false)  		if err == nil {  			targetAccount = a  		} diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index a32dd9ac0..4d10f1d0c 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" @@ -137,68 +135,57 @@ 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("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: 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("could not read provided avatar: %s", err) +	dataFunc := func(innerCtx context.Context) (io.Reader, int, error) { +		f, err := avatar.Open() +		return f, int(avatar.Size), err  	} -	if size == 0 { -		return nil, errors.New("could not read provided avatar: size 0 bytes") + +	isAvatar := true +	ai := &media.AdditionalMediaInfo{ +		Avatar: &isAvatar,  	} -	// do the setting -	avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(ctx, buf.Bytes(), accountID, media.TypeAvatar, "") +	processingMedia, err := p.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai)  	if err != nil { -		return nil, fmt.Errorf("error processing avatar: %s", err) +		return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err)  	} -	return avatarInfo, f.Close() +	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("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: 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("could not read provided header: %s", err) +	dataFunc := func(innerCtx context.Context) (io.Reader, int, error) { +		f, err := header.Open() +		return f, int(header.Size), err  	} -	if size == 0 { -		return nil, errors.New("could not read provided header: size 0 bytes") + +	isHeader := true +	ai := &media.AdditionalMediaInfo{ +		Header: &isHeader,  	} -	// do the setting -	headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(ctx, buf.Bytes(), accountID, media.TypeHeader, "") +	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("error processing header: %s", err) +		return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err)  	} -	return headerInfo, f.Close() +	return processingMedia.LoadAttachment(ctx)  }  func (p *processor) processNote(ctx context.Context, note string, accountID string) (string, error) { diff --git a/internal/processing/admin.go b/internal/processing/admin.go index c70bd79d0..764e6d302 100644 --- a/internal/processing/admin.go +++ b/internal/processing/admin.go @@ -26,7 +26,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  ) -func (p *processor) AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { +func (p *processor) AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode) {  	return p.adminProcessor.EmojiCreate(ctx, authed.Account, authed.User, form)  } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 217d10dfe..bdb586588 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -38,21 +38,21 @@ type Processor interface {  	DomainBlocksGet(ctx context.Context, account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode)  	DomainBlockGet(ctx context.Context, account *gtsmodel.Account, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode)  	DomainBlockDelete(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode) -	EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) +	EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)  }  type processor struct {  	tc            typeutils.TypeConverter -	mediaHandler  media.Handler +	mediaManager  media.Manager  	fromClientAPI chan messages.FromClientAPI  	db            db.DB  }  // New returns a new admin processor. -func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, fromClientAPI chan messages.FromClientAPI) Processor { +func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, fromClientAPI chan messages.FromClientAPI) Processor {  	return &processor{  		tc:            tc, -		mediaHandler:  mediaHandler, +		mediaManager:  mediaManager,  		fromClientAPI: fromClientAPI,  		db:            db,  	} diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index 4989d8e8d..6ef78aa65 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -19,55 +19,53 @@  package admin  import ( -	"bytes"  	"context"  	"errors"  	"fmt"  	"io"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/uris"  ) -func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { -	if user.Admin { -		return nil, fmt.Errorf("user %s not an admin", user.ID) +func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode) { +	if !user.Admin { +		return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin")  	} -	// 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) +	data := func(innerCtx context.Context) (io.Reader, int, error) { +		f, err := form.Image.Open() +		return f, int(form.Image.Size), err  	} -	buf := new(bytes.Buffer) -	size, err := io.Copy(buf, f) + +	emojiID, err := id.NewRandomULID()  	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 nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating id for new emoji: %s", err), "error creating emoji ID")  	} -	// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using -	emoji, err := p.mediaHandler.ProcessLocalEmoji(ctx, buf.Bytes(), form.Shortcode) +	emojiURI := uris.GenerateURIForEmoji(emojiID) + +	processingEmoji, err := p.mediaManager.ProcessEmoji(ctx, data, form.Shortcode, emojiID, emojiURI, nil)  	if err != nil { -		return nil, fmt.Errorf("error reading emoji: %s", err) +		return nil, gtserror.NewErrorInternalError(fmt.Errorf("error processing emoji: %s", err), "error processing emoji")  	} -	emojiID, err := id.NewULID() +	emoji, err := processingEmoji.LoadEmoji(ctx)  	if err != nil { -		return nil, err +		var alreadyExistsError *db.ErrAlreadyExists +		if errors.As(err, &alreadyExistsError) { +			return nil, gtserror.NewErrorConflict(fmt.Errorf("emoji with shortcode %s already exists", form.Shortcode), fmt.Sprintf("emoji with shortcode %s already exists", form.Shortcode)) +		} +		return nil, gtserror.NewErrorInternalError(fmt.Errorf("error loading emoji: %s", err), "error loading emoji")  	} -	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 nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting emoji: %s", err), "error converting emoji to api representation")  	}  	return &apiEmoji, nil diff --git a/internal/processing/federation/getfollowers.go b/internal/processing/federation/getfollowers.go index 9153cde1e..c15b2b6c4 100644 --- a/internal/processing/federation/getfollowers.go +++ b/internal/processing/federation/getfollowers.go @@ -41,7 +41,7 @@ func (p *processor) GetFollowers(ctx context.Context, requestedUsername string,  		return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")  	} -	requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +	requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  	if err != nil {  		return nil, gtserror.NewErrorNotAuthorized(err)  	} diff --git a/internal/processing/federation/getfollowing.go b/internal/processing/federation/getfollowing.go index 8a3025154..d2beaada0 100644 --- a/internal/processing/federation/getfollowing.go +++ b/internal/processing/federation/getfollowing.go @@ -41,7 +41,7 @@ func (p *processor) GetFollowing(ctx context.Context, requestedUsername string,  		return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")  	} -	requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +	requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  	if err != nil {  		return nil, gtserror.NewErrorNotAuthorized(err)  	} diff --git a/internal/processing/federation/getoutbox.go b/internal/processing/federation/getoutbox.go index 0f2043447..944c0b571 100644 --- a/internal/processing/federation/getoutbox.go +++ b/internal/processing/federation/getoutbox.go @@ -42,7 +42,7 @@ func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, pag  		return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")  	} -	requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +	requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  	if err != nil {  		return nil, gtserror.NewErrorNotAuthorized(err)  	} diff --git a/internal/processing/federation/getstatus.go b/internal/processing/federation/getstatus.go index f065eaa71..1651516b5 100644 --- a/internal/processing/federation/getstatus.go +++ b/internal/processing/federation/getstatus.go @@ -43,7 +43,7 @@ func (p *processor) GetStatus(ctx context.Context, requestedUsername string, req  		return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")  	} -	requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +	requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  	if err != nil {  		return nil, gtserror.NewErrorNotAuthorized(err)  	} diff --git a/internal/processing/federation/getstatusreplies.go b/internal/processing/federation/getstatusreplies.go index 4fc21e3ad..c6db4dd3e 100644 --- a/internal/processing/federation/getstatusreplies.go +++ b/internal/processing/federation/getstatusreplies.go @@ -43,7 +43,7 @@ func (p *processor) GetStatusReplies(ctx context.Context, requestedUsername stri  		return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")  	} -	requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +	requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  	if err != nil {  		return nil, gtserror.NewErrorNotAuthorized(err)  	} diff --git a/internal/processing/federation/getuser.go b/internal/processing/federation/getuser.go index a8d6bcf38..6d5b8463f 100644 --- a/internal/processing/federation/getuser.go +++ b/internal/processing/federation/getuser.go @@ -54,7 +54,7 @@ func (p *processor) GetUser(ctx context.Context, requestedUsername string, reque  		// if we're not already handshaking/dereferencing a remote account, dereference it now  		if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) { -			requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false) +			requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false)  			if err != nil {  				return nil, gtserror.NewErrorNotAuthorized(err)  			} diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go index 3083bcc40..bb2cb5323 100644 --- a/internal/processing/fromfederator.go +++ b/internal/processing/fromfederator.go @@ -22,6 +22,7 @@ import (  	"context"  	"errors"  	"fmt" +	"net/url"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/ap" @@ -114,6 +115,30 @@ func (p *processor) processCreateStatusFromFederator(ctx context.Context, federa  		}  	} +	// make sure the account is pinned +	if status.Account == nil { +		a, err := p.db.GetAccountByID(ctx, status.AccountID) +		if err != nil { +			return err +		} +		status.Account = a +	} + +	// do a BLOCKING get of the remote account to make sure the avi and header are cached +	if status.Account.Domain != "" { +		remoteAccountID, err := url.Parse(status.Account.URI) +		if err != nil { +			return err +		} + +		a, err := p.federator.GetRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, remoteAccountID, true, false) +		if err != nil { +			return err +		} + +		status.Account = a +	} +  	if err := p.timelineStatus(ctx, status); err != nil {  		return err  	} @@ -132,6 +157,30 @@ func (p *processor) processCreateFaveFromFederator(ctx context.Context, federato  		return errors.New("like was not parseable as *gtsmodel.StatusFave")  	} +	// make sure the account is pinned +	if incomingFave.Account == nil { +		a, err := p.db.GetAccountByID(ctx, incomingFave.AccountID) +		if err != nil { +			return err +		} +		incomingFave.Account = a +	} + +	// do a BLOCKING get of the remote account to make sure the avi and header are cached +	if incomingFave.Account.Domain != "" { +		remoteAccountID, err := url.Parse(incomingFave.Account.URI) +		if err != nil { +			return err +		} + +		a, err := p.federator.GetRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, remoteAccountID, true, false) +		if err != nil { +			return err +		} + +		incomingFave.Account = a +	} +  	if err := p.notifyFave(ctx, incomingFave); err != nil {  		return err  	} @@ -146,6 +195,30 @@ func (p *processor) processCreateFollowRequestFromFederator(ctx context.Context,  		return errors.New("incomingFollowRequest was not parseable as *gtsmodel.FollowRequest")  	} +	// make sure the account is pinned +	if followRequest.Account == nil { +		a, err := p.db.GetAccountByID(ctx, followRequest.AccountID) +		if err != nil { +			return err +		} +		followRequest.Account = a +	} + +	// do a BLOCKING get of the remote account to make sure the avi and header are cached +	if followRequest.Account.Domain != "" { +		remoteAccountID, err := url.Parse(followRequest.Account.URI) +		if err != nil { +			return err +		} + +		a, err := p.federator.GetRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, remoteAccountID, true, false) +		if err != nil { +			return err +		} + +		followRequest.Account = a +	} +  	if followRequest.TargetAccount == nil {  		a, err := p.db.GetAccountByID(ctx, followRequest.TargetAccountID)  		if err != nil { @@ -153,9 +226,8 @@ func (p *processor) processCreateFollowRequestFromFederator(ctx context.Context,  		}  		followRequest.TargetAccount = a  	} -	targetAccount := followRequest.TargetAccount -	if targetAccount.Locked { +	if followRequest.TargetAccount.Locked {  		// if the account is locked just notify the follow request and nothing else  		return p.notifyFollowRequest(ctx, followRequest)  	} @@ -170,7 +242,7 @@ func (p *processor) processCreateFollowRequestFromFederator(ctx context.Context,  		return err  	} -	return p.notifyFollow(ctx, follow, targetAccount) +	return p.notifyFollow(ctx, follow, followRequest.TargetAccount)  }  // processCreateAnnounceFromFederator handles Activity Create and Object Announce @@ -180,6 +252,30 @@ func (p *processor) processCreateAnnounceFromFederator(ctx context.Context, fede  		return errors.New("announce was not parseable as *gtsmodel.Status")  	} +	// make sure the account is pinned +	if incomingAnnounce.Account == nil { +		a, err := p.db.GetAccountByID(ctx, incomingAnnounce.AccountID) +		if err != nil { +			return err +		} +		incomingAnnounce.Account = a +	} + +	// do a BLOCKING get of the remote account to make sure the avi and header are cached +	if incomingAnnounce.Account.Domain != "" { +		remoteAccountID, err := url.Parse(incomingAnnounce.Account.URI) +		if err != nil { +			return err +		} + +		a, err := p.federator.GetRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, remoteAccountID, true, false) +		if err != nil { +			return err +		} + +		incomingAnnounce.Account = a +	} +  	if err := p.federator.DereferenceAnnounce(ctx, incomingAnnounce, federatorMsg.ReceivingAccount.Username); err != nil {  		return fmt.Errorf("error dereferencing announce from federator: %s", err)  	} @@ -232,7 +328,12 @@ func (p *processor) processUpdateAccountFromFederator(ctx context.Context, feder  		return errors.New("profile was not parseable as *gtsmodel.Account")  	} -	if _, err := p.federator.EnrichRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, incomingAccount); err != nil { +	incomingAccountURL, err := url.Parse(incomingAccount.URI) +	if err != nil { +		return err +	} + +	if _, err := p.federator.GetRemoteAccount(ctx, federatorMsg.ReceivingAccount.Username, incomingAccountURL, false, true); err != nil {  		return fmt.Errorf("error enriching updated account from federator: %s", err)  	} diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index de15d3162..4047278eb 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -19,56 +19,39 @@  package media  import ( -	"bytes"  	"context" -	"errors"  	"fmt"  	"io" -	"time"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/text" +	"github.com/superseriousbusiness/gotosocial/internal/media"  )  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) (io.Reader, int, error) { +		f, err := form.File.Open() +		return f, int(form.File.Size), err  	} -	// now parse the focus parameter -	focusx, focusy, err := parseFocus(form.Focus) +	focusX, focusY, err := parseFocus(form.Focus)  	if err != nil { -		return nil, fmt.Errorf("couldn't parse attachment focus: %s", err) +		return nil, fmt.Errorf("could not parse focus value %s: %s", form.Focus, 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, -			}, -		}, +	// process the media attachment and load it immediately +	media, err := p.mediaManager.ProcessMedia(ctx, data, account.ID, &media.AdditionalMediaInfo{ +		Description: &form.Description, +		FocusX:      &focusX, +		FocusY:      &focusY, +	}) +	if err != nil { +		return nil, err  	} -	// 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) +	attachment, err := media.LoadAttachment(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 @@ -78,10 +61,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/processing/media/media.go b/internal/processing/media/media.go index 9e050fe84..3d4ae5009 100644 --- a/internal/processing/media/media.go +++ b/internal/processing/media/media.go @@ -43,16 +43,16 @@ type Processor interface {  type processor struct {  	tc           typeutils.TypeConverter -	mediaHandler media.Handler +	mediaManager media.Manager  	storage      *kv.KVStore  	db           db.DB  }  // New returns a new media processor. -func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, storage *kv.KVStore) Processor { +func New(db db.DB, tc typeutils.TypeConverter, mediaManager media.Manager, storage *kv.KVStore) Processor {  	return &processor{  		tc:           tc, -		mediaHandler: mediaHandler, +		mediaManager: mediaManager,  		storage:      storage,  		db:           db,  	} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 13fc08c26..46d17a160 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -96,7 +96,7 @@ type Processor interface {  	AccountBlockRemove(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode)  	// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. -	AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) +	AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)  	// AdminDomainBlockCreate handles the creation of a new domain block by an admin, using the given form.  	AdminDomainBlockCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode)  	// AdminDomainBlocksImport handles the import of multiple domain blocks by an admin, using the given form. @@ -235,7 +235,7 @@ type processor struct {  	stop            chan interface{}  	tc              typeutils.TypeConverter  	oauthServer     oauth.Server -	mediaHandler    media.Handler +	mediaManager    media.Manager  	storage         *kv.KVStore  	statusTimelines timeline.Manager  	db              db.DB @@ -259,7 +259,7 @@ func NewProcessor(  	tc typeutils.TypeConverter,  	federator federation.Federator,  	oauthServer oauth.Server, -	mediaHandler media.Handler, +	mediaManager media.Manager,  	storage *kv.KVStore,  	db db.DB,  	emailSender email.Sender) Processor { @@ -268,9 +268,9 @@ func NewProcessor(  	statusProcessor := status.New(db, tc, fromClientAPI)  	streamingProcessor := streaming.New(db, oauthServer) -	accountProcessor := account.New(db, tc, mediaHandler, oauthServer, fromClientAPI, federator) -	adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI) -	mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage) +	accountProcessor := account.New(db, tc, mediaManager, oauthServer, fromClientAPI, federator) +	adminProcessor := admin.New(db, tc, mediaManager, fromClientAPI) +	mediaProcessor := mediaProcessor.New(db, tc, mediaManager, storage)  	userProcessor := user.New(db, emailSender)  	federationProcessor := federationProcessor.New(db, tc, federator, fromFederator)  	filter := visibility.NewFilter(db) @@ -282,7 +282,7 @@ func NewProcessor(  		stop:            make(chan interface{}),  		tc:              tc,  		oauthServer:     oauthServer, -		mediaHandler:    mediaHandler, +		mediaManager:    mediaManager,  		storage:         storage,  		statusTimelines: timeline.NewManager(StatusGrabFunction(db), StatusFilterFunction(db, filter), StatusPrepareFunction(db, tc), StatusSkipInsertFunction()),  		db:              db, diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index 34b8f40bb..794bcc197 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 -	mediaHandler        media.Handler  	timelineManager     timeline.Manager  	emailSender         email.Sender @@ -216,12 +216,12 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {  	})  	suite.transportController = testrig.NewTestTransportController(httpClient, suite.db) -	suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage) +	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.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)  	suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) -	suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaHandler, suite.storage, suite.db, suite.emailSender) +	suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaManager, suite.storage, suite.db, suite.emailSender)  	testrig.StandardDBSetup(suite.db, suite.testAccounts)  	testrig.StandardStorageSetup(suite.storage, "../../testrig/media") diff --git a/internal/processing/search.go b/internal/processing/search.go index b03ced831..c8c302857 100644 --- a/internal/processing/search.go +++ b/internal/processing/search.go @@ -148,7 +148,7 @@ func (p *processor) searchAccountByURI(ctx context.Context, authed *oauth.Auth,  	if resolve {  		// we don't have it locally so try and dereference it -		account, _, err := p.federator.GetRemoteAccount(ctx, authed.Account.Username, uri, true) +		account, err := p.federator.GetRemoteAccount(ctx, authed.Account.Username, uri, true, true)  		if err != nil {  			return nil, fmt.Errorf("searchAccountByURI: error dereferencing account with uri %s: %s", uri.String(), err)  		} @@ -203,7 +203,7 @@ func (p *processor) searchAccountByMention(ctx context.Context, authed *oauth.Au  		}  		// we don't have it locally so try and dereference it -		account, _, err := p.federator.GetRemoteAccount(ctx, authed.Account.Username, acctURI, true) +		account, err := p.federator.GetRemoteAccount(ctx, authed.Account.Username, acctURI, true, true)  		if err != nil {  			return nil, fmt.Errorf("searchAccountByMention: error dereferencing account with uri %s: %s", acctURI.String(), err)  		} diff --git a/internal/transport/derefmedia.go b/internal/transport/derefmedia.go index 8a6aa4e24..e3c86ce1e 100644 --- a/internal/transport/derefmedia.go +++ b/internal/transport/derefmedia.go @@ -21,25 +21,22 @@ 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, expectedContentType string) ([]byte, 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 -	} -	if expectedContentType == "" { -		req.Header.Add("Accept", "*/*") -	} else { -		req.Header.Add("Accept", expectedContentType) +		return nil, 0, err  	} + +	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) @@ -47,15 +44,14 @@ func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL, expected  	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  	} -	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 nil, 0, fmt.Errorf("GET request to %s failed (%d): %s", iri.String(), resp.StatusCode, resp.Status)  	} -	return ioutil.ReadAll(resp.Body) +	return resp.Body, int(resp.ContentLength), nil  } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 73b015865..9e8cd8213 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, with the expectedContentType. -	DereferenceMedia(ctx context.Context, iri *url.URL, expectedContentType string) ([]byte, 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. diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 3adf406cf..f154398ce 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -25,6 +25,7 @@ import (  	"fmt"  	"net/url" +	"github.com/sirupsen/logrus"  	"github.com/spf13/viper"  	"github.com/superseriousbusiness/activity/pub"  	"github.com/superseriousbusiness/activity/streams" @@ -215,62 +216,68 @@ func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab  	// Used as profile avatar.  	if a.AvatarMediaAttachmentID != "" {  		if a.AvatarMediaAttachment == nil { -			avatar := >smodel.MediaAttachment{} -			if err := c.db.GetByID(ctx, a.AvatarMediaAttachmentID, avatar); err != nil { -				return nil, err +			avatar, err := c.db.GetAttachmentByID(ctx, a.AvatarMediaAttachmentID) +			if err == nil { +				a.AvatarMediaAttachment = avatar +			} else { +				logrus.Errorf("AccountToAS: error getting Avatar with id %s: %s", a.AvatarMediaAttachmentID, err)  			} -			a.AvatarMediaAttachment = avatar  		} -		iconProperty := streams.NewActivityStreamsIconProperty() +		if a.AvatarMediaAttachment != nil { +			iconProperty := streams.NewActivityStreamsIconProperty() -		iconImage := streams.NewActivityStreamsImage() +			iconImage := streams.NewActivityStreamsImage() -		mediaType := streams.NewActivityStreamsMediaTypeProperty() -		mediaType.Set(a.AvatarMediaAttachment.File.ContentType) -		iconImage.SetActivityStreamsMediaType(mediaType) +			mediaType := streams.NewActivityStreamsMediaTypeProperty() +			mediaType.Set(a.AvatarMediaAttachment.File.ContentType) +			iconImage.SetActivityStreamsMediaType(mediaType) -		avatarURLProperty := streams.NewActivityStreamsUrlProperty() -		avatarURL, err := url.Parse(a.AvatarMediaAttachment.URL) -		if err != nil { -			return nil, err -		} -		avatarURLProperty.AppendIRI(avatarURL) -		iconImage.SetActivityStreamsUrl(avatarURLProperty) +			avatarURLProperty := streams.NewActivityStreamsUrlProperty() +			avatarURL, err := url.Parse(a.AvatarMediaAttachment.URL) +			if err != nil { +				return nil, err +			} +			avatarURLProperty.AppendIRI(avatarURL) +			iconImage.SetActivityStreamsUrl(avatarURLProperty) -		iconProperty.AppendActivityStreamsImage(iconImage) -		person.SetActivityStreamsIcon(iconProperty) +			iconProperty.AppendActivityStreamsImage(iconImage) +			person.SetActivityStreamsIcon(iconProperty) +		}  	}  	// image  	// Used as profile header.  	if a.HeaderMediaAttachmentID != "" {  		if a.HeaderMediaAttachment == nil { -			header := >smodel.MediaAttachment{} -			if err := c.db.GetByID(ctx, a.HeaderMediaAttachmentID, header); err != nil { -				return nil, err +			header, err := c.db.GetAttachmentByID(ctx, a.HeaderMediaAttachmentID) +			if err == nil { +				a.HeaderMediaAttachment = header +			} else { +				logrus.Errorf("AccountToAS: error getting Header with id %s: %s", a.HeaderMediaAttachmentID, err)  			} -			a.HeaderMediaAttachment = header  		} -		headerProperty := streams.NewActivityStreamsImageProperty() +		if a.HeaderMediaAttachment != nil { +			headerProperty := streams.NewActivityStreamsImageProperty() -		headerImage := streams.NewActivityStreamsImage() +			headerImage := streams.NewActivityStreamsImage() -		mediaType := streams.NewActivityStreamsMediaTypeProperty() -		mediaType.Set(a.HeaderMediaAttachment.File.ContentType) -		headerImage.SetActivityStreamsMediaType(mediaType) +			mediaType := streams.NewActivityStreamsMediaTypeProperty() +			mediaType.Set(a.HeaderMediaAttachment.File.ContentType) +			headerImage.SetActivityStreamsMediaType(mediaType) -		headerURLProperty := streams.NewActivityStreamsUrlProperty() -		headerURL, err := url.Parse(a.HeaderMediaAttachment.URL) -		if err != nil { -			return nil, err -		} -		headerURLProperty.AppendIRI(headerURL) -		headerImage.SetActivityStreamsUrl(headerURLProperty) +			headerURLProperty := streams.NewActivityStreamsUrlProperty() +			headerURL, err := url.Parse(a.HeaderMediaAttachment.URL) +			if err != nil { +				return nil, err +			} +			headerURLProperty.AppendIRI(headerURL) +			headerImage.SetActivityStreamsUrl(headerURLProperty) -		headerProperty.AppendActivityStreamsImage(headerImage) -		person.SetActivityStreamsImage(headerProperty) +			headerProperty.AppendActivityStreamsImage(headerImage) +			person.SetActivityStreamsImage(headerProperty) +		}  	}  	return person, nil diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 8236fb3ae..39a2861fb 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -96,35 +96,40 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A  		lastStatusAt = lastPosted.Format(time.RFC3339)  	} -	// build the avatar and header URLs +	// set account avatar fields if available  	var aviURL string  	var aviURLStatic string  	if a.AvatarMediaAttachmentID != "" { -		// make sure avi is pinned to this account  		if a.AvatarMediaAttachment == nil {  			avi, err := c.db.GetAttachmentByID(ctx, a.AvatarMediaAttachmentID) -			if err != nil { -				return nil, fmt.Errorf("error retrieving avatar: %s", err) +			if err == nil { +				a.AvatarMediaAttachment = avi +			} else { +				logrus.Errorf("AccountToAPIAccountPublic: error getting Avatar with id %s: %s", a.AvatarMediaAttachmentID, err)  			} -			a.AvatarMediaAttachment = avi  		} -		aviURL = a.AvatarMediaAttachment.URL -		aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL +		if a.AvatarMediaAttachment != nil { +			aviURL = a.AvatarMediaAttachment.URL +			aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL +		}  	} +	// set account header fields if available  	var headerURL string  	var headerURLStatic string  	if a.HeaderMediaAttachmentID != "" { -		// make sure header is pinned to this account  		if a.HeaderMediaAttachment == nil {  			avi, err := c.db.GetAttachmentByID(ctx, a.HeaderMediaAttachmentID) -			if err != nil { -				return nil, fmt.Errorf("error retrieving avatar: %s", err) +			if err == nil { +				a.HeaderMediaAttachment = avi +			} else { +				logrus.Errorf("AccountToAPIAccountPublic: error getting Header with id %s: %s", a.HeaderMediaAttachmentID, err)  			} -			a.HeaderMediaAttachment = avi  		} -		headerURL = a.HeaderMediaAttachment.URL -		headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL +		if a.HeaderMediaAttachment != nil { +			headerURL = a.HeaderMediaAttachment.URL +			headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL +		}  	}  	// get the fields set on this account | 
