summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/ap/extract.go18
-rw-r--r--internal/ap/extractattachments_test.go2
-rw-r--r--internal/ap/interfaces.go8
-rw-r--r--internal/api/client/account/account_test.go21
-rw-r--r--internal/api/client/account/accountverify_test.go2
-rw-r--r--internal/api/client/admin/admin.go2
-rw-r--r--internal/api/client/admin/admin_test.go123
-rw-r--r--internal/api/client/admin/emojicreate.go20
-rw-r--r--internal/api/client/admin/emojicreate_test.go128
-rw-r--r--internal/api/client/fileserver/servefile_test.go8
-rw-r--r--internal/api/client/followrequest/followrequest_test.go17
-rw-r--r--internal/api/client/media/mediacreate_test.go8
-rw-r--r--internal/api/client/status/status_test.go19
-rw-r--r--internal/api/client/user/user_test.go19
-rw-r--r--internal/api/s2s/user/inboxpost_test.go16
-rw-r--r--internal/api/s2s/user/outboxget_test.go12
-rw-r--r--internal/api/s2s/user/repliesget_test.go12
-rw-r--r--internal/api/s2s/user/user_test.go7
-rw-r--r--internal/api/s2s/user/userget_test.go4
-rw-r--r--internal/api/s2s/webfinger/webfinger_test.go7
-rw-r--r--internal/db/bundb/errors.go4
-rw-r--r--internal/db/bundb/trace.go9
-rw-r--r--internal/federation/dereference.go8
-rw-r--r--internal/federation/dereferencing/account.go315
-rw-r--r--internal/federation/dereferencing/account_test.go3
-rw-r--r--internal/federation/dereferencing/attachment.go104
-rw-r--r--internal/federation/dereferencing/dereferencer.go64
-rw-r--r--internal/federation/dereferencing/dereferencer_test.go2
-rw-r--r--internal/federation/dereferencing/media.go55
-rw-r--r--internal/federation/dereferencing/media_test.go (renamed from internal/federation/dereferencing/attachment_test.go)90
-rw-r--r--internal/federation/dereferencing/status.go21
-rw-r--r--internal/federation/federatingprotocol.go2
-rw-r--r--internal/federation/federator.go11
-rw-r--r--internal/federation/federator_test.go4
-rw-r--r--internal/gotosocial/gotosocial.go26
-rw-r--r--internal/gtserror/withcode.go13
-rw-r--r--internal/media/handler.go318
-rw-r--r--internal/media/image.go198
-rw-r--r--internal/media/manager.go176
-rw-r--r--internal/media/manager_test.go363
-rw-r--r--internal/media/media_test.go54
-rw-r--r--internal/media/processicon.go143
-rw-r--r--internal/media/processimage.go133
-rw-r--r--internal/media/processingemoji.go291
-rw-r--r--internal/media/processingmedia.go410
-rw-r--r--internal/media/processvideo.go23
-rw-r--r--internal/media/test/test-corrupted.jpg1
-rw-r--r--internal/media/test/test-jpeg-blurhash.jpgbin8802 -> 0 bytes
-rw-r--r--internal/media/test/test-with-exif.jpgbin1227452 -> 0 bytes
-rw-r--r--internal/media/test/test-without-exif.jpgbin1227452 -> 0 bytes
-rw-r--r--internal/media/types.go121
-rw-r--r--internal/media/util.go247
-rw-r--r--internal/media/util_test.go150
-rw-r--r--internal/processing/account/account.go6
-rw-r--r--internal/processing/account/account_test.go8
-rw-r--r--internal/processing/account/get.go8
-rw-r--r--internal/processing/account/update.go63
-rw-r--r--internal/processing/admin.go2
-rw-r--r--internal/processing/admin/admin.go8
-rw-r--r--internal/processing/admin/emoji.go48
-rw-r--r--internal/processing/federation/getfollowers.go2
-rw-r--r--internal/processing/federation/getfollowing.go2
-rw-r--r--internal/processing/federation/getoutbox.go2
-rw-r--r--internal/processing/federation/getstatus.go2
-rw-r--r--internal/processing/federation/getstatusreplies.go2
-rw-r--r--internal/processing/federation/getuser.go2
-rw-r--r--internal/processing/fromfederator.go109
-rw-r--r--internal/processing/media/create.go54
-rw-r--r--internal/processing/media/media.go6
-rw-r--r--internal/processing/processor.go14
-rw-r--r--internal/processing/processor_test.go8
-rw-r--r--internal/processing/search.go4
-rw-r--r--internal/transport/derefmedia.go22
-rw-r--r--internal/transport/transport.go5
-rw-r--r--internal/typeutils/internaltoas.go74
-rw-r--r--internal/typeutils/internaltofrontend.go27
76 files changed, 2695 insertions, 1595 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 := &gtsmodel.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/status/status_test.go b/internal/api/client/status/status_test.go
index dd037f6f4..c6e5b354f 100644
--- a/internal/api/client/status/status_test.go
+++ b/internal/api/client/status/status_test.go
@@ -26,6 +26,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
@@ -34,12 +35,13 @@ import (
type StatusStandardTestSuite struct {
// standard suite interfaces
suite.Suite
- db db.DB
- tc typeutils.TypeConverter
- federator federation.Federator
- emailSender email.Sender
- processor processing.Processor
- storage *kv.KVStore
+ db db.DB
+ tc typeutils.TypeConverter
+ mediaManager media.Manager
+ federator federation.Federator
+ emailSender email.Sender
+ processor processing.Processor
+ storage *kv.KVStore
// standard suite models
testTokens map[string]*gtsmodel.Token
@@ -70,9 +72,10 @@ func (suite *StatusStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.storage = testrig.NewTestStorage()
- suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage)
+ suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender)
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager)
suite.statusModule = status.New(suite.processor).(*status.Module)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go
index d60bf2f1a..e84a78cde 100644
--- a/internal/api/client/user/user_test.go
+++ b/internal/api/client/user/user_test.go
@@ -26,6 +26,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
@@ -33,12 +34,13 @@ import (
type UserStandardTestSuite struct {
suite.Suite
- db db.DB
- tc typeutils.TypeConverter
- federator federation.Federator
- emailSender email.Sender
- processor processing.Processor
- storage *kv.KVStore
+ db db.DB
+ tc typeutils.TypeConverter
+ mediaManager media.Manager
+ federator federation.Federator
+ emailSender email.Sender
+ processor processing.Processor
+ storage *kv.KVStore
testTokens map[string]*gtsmodel.Token
testClients map[string]*gtsmodel.Client
@@ -62,10 +64,11 @@ func (suite *UserStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB()
suite.storage = testrig.NewTestStorage()
suite.tc = testrig.NewTestTypeConverter(suite.db)
- suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage)
+ suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender)
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager)
suite.userModule = user.New(suite.processor).(*user.Module)
testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go
index 7cd9ce93a..2f43799c8 100644
--- a/internal/api/s2s/user/inboxpost_test.go
+++ b/internal/api/s2s/user/inboxpost_test.go
@@ -84,9 +84,9 @@ func (suite *InboxPostTestSuite) TestPostBlock() {
body := bytes.NewReader(bodyJson)
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -184,9 +184,9 @@ func (suite *InboxPostTestSuite) TestPostUnblock() {
body := bytes.NewReader(bodyJson)
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -274,9 +274,9 @@ func (suite *InboxPostTestSuite) TestPostUpdate() {
body := bytes.NewReader(bodyJson)
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -393,9 +393,9 @@ func (suite *InboxPostTestSuite) TestPostDelete() {
body := bytes.NewReader(bodyJson)
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
err = processor.Start(context.Background())
suite.NoError(err)
userModule := user.New(processor).(*user.Module)
diff --git a/internal/api/s2s/user/outboxget_test.go b/internal/api/s2s/user/outboxget_test.go
index 2591a80f1..3f5b54c07 100644
--- a/internal/api/s2s/user/outboxget_test.go
+++ b/internal/api/s2s/user/outboxget_test.go
@@ -45,9 +45,9 @@ func (suite *OutboxGetTestSuite) TestGetOutbox() {
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -100,9 +100,9 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() {
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -155,9 +155,9 @@ func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() {
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
diff --git a/internal/api/s2s/user/repliesget_test.go b/internal/api/s2s/user/repliesget_test.go
index d128e30da..81249091c 100644
--- a/internal/api/s2s/user/repliesget_test.go
+++ b/internal/api/s2s/user/repliesget_test.go
@@ -48,9 +48,9 @@ func (suite *RepliesGetTestSuite) TestGetReplies() {
targetStatus := suite.testStatuses["local_account_1_status_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -109,9 +109,9 @@ func (suite *RepliesGetTestSuite) TestGetRepliesNext() {
targetStatus := suite.testStatuses["local_account_1_status_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
@@ -173,9 +173,9 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() {
targetStatus := suite.testStatuses["local_account_1_status_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go
index 43b835165..b1be59073 100644
--- a/internal/api/s2s/user/user_test.go
+++ b/internal/api/s2s/user/user_test.go
@@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
@@ -38,6 +39,7 @@ type UserStandardTestSuite struct {
suite.Suite
db db.DB
tc typeutils.TypeConverter
+ mediaManager media.Manager
federator federation.Federator
emailSender email.Sender
processor processing.Processor
@@ -77,9 +79,10 @@ func (suite *UserStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.storage = testrig.NewTestStorage()
- suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage)
+ suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender)
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager)
suite.userModule = user.New(suite.processor).(*user.Module)
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module)
diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go
index 2f3109c92..a764f6993 100644
--- a/internal/api/s2s/user/userget_test.go
+++ b/internal/api/s2s/user/userget_test.go
@@ -46,9 +46,9 @@ func (suite *UserGetTestSuite) TestGetUser() {
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
- federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
+ federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
- processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager)
userModule := user.New(processor).(*user.Module)
// setup request
diff --git a/internal/api/s2s/webfinger/webfinger_test.go b/internal/api/s2s/webfinger/webfinger_test.go
index 7e57f7e45..d7b1647b0 100644
--- a/internal/api/s2s/webfinger/webfinger_test.go
+++ b/internal/api/s2s/webfinger/webfinger_test.go
@@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
@@ -43,6 +44,7 @@ type WebfingerStandardTestSuite struct {
suite.Suite
db db.DB
tc typeutils.TypeConverter
+ mediaManager media.Manager
federator federation.Federator
emailSender email.Sender
processor processing.Processor
@@ -80,9 +82,10 @@ func (suite *WebfingerStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.storage = testrig.NewTestStorage()
- suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage)
+ suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage, suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender)
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager)
suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module)
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module)
diff --git a/internal/db/bundb/errors.go b/internal/db/bundb/errors.go
index d01720731..113679226 100644
--- a/internal/db/bundb/errors.go
+++ b/internal/db/bundb/errors.go
@@ -35,8 +35,8 @@ func processSQLiteError(err error) db.Error {
// Handle supplied error code:
switch sqliteErr.Code() {
- case sqlite3.SQLITE_CONSTRAINT_UNIQUE:
- return db.NewErrAlreadyExists(err.Error())
+ 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..d87192d3a 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,191 @@ 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, &gtsmodel.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
+
+ // first check if we're already processing this media
+ d.dereferencingAvatarsLock.Lock()
+ if alreadyProcessing, ok := d.dereferencingAvatars[targetAccount.ID]; ok {
+ // we're already on it, no worries
+ processingMedia = alreadyProcessing
}
- targetAccount.AvatarMediaAttachmentID = a.ID
+ d.dereferencingAvatarsLock.Unlock()
+
+ if processingMedia == nil {
+ // we're not already processing it so start now
+ avatarIRI, err := url.Parse(targetAccount.AvatarRemoteURL)
+ if err != nil {
+ 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 {
+ return changed, err
+ }
+
+ // store it in our map to indicate it's in process
+ d.dereferencingAvatarsLock.Lock()
+ d.dereferencingAvatars[targetAccount.ID] = newProcessing
+ d.dereferencingAvatarsLock.Unlock()
+
+ processingMedia = newProcessing
+ }
+
+ // 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, &gtsmodel.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
+
+ // first check if we're already processing this media
+ d.dereferencingHeadersLock.Lock()
+ if alreadyProcessing, ok := d.dereferencingHeaders[targetAccount.ID]; ok {
+ // we're already on it, no worries
+ processingMedia = alreadyProcessing
+ }
+ d.dereferencingHeadersLock.Unlock()
+
+ if processingMedia == nil {
+ // we're not already processing it so start now
+ headerIRI, err := url.Parse(targetAccount.HeaderRemoteURL)
+ if err != nil {
+ 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 {
+ return changed, err
+ }
+
+ // store it in our map to indicate it's in process
+ d.dereferencingHeadersLock.Lock()
+ d.dereferencingHeaders[targetAccount.ID] = newProcessing
+ d.dereferencingHeadersLock.Unlock()
+
+ processingMedia = newProcessing
}
- targetAccount.HeaderMediaAttachmentID = a.ID
+
+ // 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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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..292712427
--- /dev/null
+++ b/internal/media/processingemoji.go
@@ -0,0 +1,291 @@
+/*
+ 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"
+ "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 processState
+ fullSizeState processState
+
+ /*
+ 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 p.staticState == complete && p.fullSizeState == complete
+}
+
+func (p *ProcessingEmoji) loadStatic(ctx context.Context) error {
+ switch p.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)
+ p.staticState = 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)
+ p.staticState = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadStatic: error closing stored full size: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ // put the static in storage
+ if err := p.storage.Put(p.emoji.ImageStaticPath, static.small); err != nil {
+ p.err = fmt.Errorf("loadStatic: error storing static: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ p.emoji.ImageStaticFileSize = len(static.small)
+
+ // we're done processing the static version of the emoji!
+ p.staticState = 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 := &gtsmodel.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: received,
+ fullSizeState: received,
+ database: m.db,
+ storage: m.storage,
+ }
+
+ return processingEmoji, nil
+}
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
new file mode 100644
index 000000000..0bbe35aee
--- /dev/null
+++ b/internal/media/processingmedia.go
@@ -0,0 +1,410 @@
+/*
+ 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"
+ "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 processState // the processing state of the media thumbnail
+ fullSizeState processState // the processing state of the full-sized media
+
+ /*
+ below pointers to database and storage are maintained so that
+ 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 p.thumbstate == complete && p.fullSizeState == complete
+}
+
+func (p *ProcessingMedia) loadThumb(ctx context.Context) error {
+ switch p.thumbstate {
+ case received:
+ // we haven't processed a thumbnail for this media yet so do it now
+
+ // 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)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // ... and into the derive thumbnail function
+ thumb, err := deriveThumbnail(stored, p.attachment.File.ContentType, createBlurhash)
+ if err != nil {
+ p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadThumb: error closing stored full size: %s", err)
+ p.thumbstate = errored
+ return 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)
+ p.thumbstate = 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!
+ p.thumbstate = 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 {
+ switch p.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)
+ p.fullSizeState = 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
+ p.fullSizeState = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadFullSize: error closing stored full size: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // set appropriate fields on the attachment based on the image we derived
+ 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
+ p.fullSizeState = 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 := &gtsmodel.MediaAttachment{
+ ID: id,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ StatusID: "",
+ URL: "", // we don't know yet because it depends on the uncalled DataFunc
+ RemoteURL: "",
+ Type: gtsmodel.FileTypeUnknown, // we don't know yet because it depends on the uncalled DataFunc
+ FileMeta: gtsmodel.FileMeta{},
+ AccountID: accountID,
+ Description: "",
+ ScheduledStatusID: "",
+ Blurhash: "",
+ Processing: gtsmodel.ProcessingStatusReceived,
+ File: file,
+ Thumbnail: thumbnail,
+ Avatar: false,
+ Header: false,
+ }
+
+ // check if we have additional info to add to the attachment,
+ // and overwrite some of the attachment fields if so
+ if ai != nil {
+ if ai.CreatedAt != nil {
+ attachment.CreatedAt = *ai.CreatedAt
+ }
+
+ if ai.StatusID != nil {
+ attachment.StatusID = *ai.StatusID
+ }
+
+ if ai.RemoteURL != nil {
+ attachment.RemoteURL = *ai.RemoteURL
+ }
+
+ if ai.Description != nil {
+ attachment.Description = *ai.Description
+ }
+
+ if ai.ScheduledStatusID != nil {
+ attachment.ScheduledStatusID = *ai.ScheduledStatusID
+ }
+
+ if ai.Blurhash != nil {
+ attachment.Blurhash = *ai.Blurhash
+ }
+
+ if ai.Avatar != nil {
+ attachment.Avatar = *ai.Avatar
+ }
+
+ if ai.Header != nil {
+ attachment.Header = *ai.Header
+ }
+
+ if ai.FocusX != nil {
+ attachment.FileMeta.Focus.X = *ai.FocusX
+ }
+
+ if ai.FocusY != nil {
+ attachment.FileMeta.Focus.Y = *ai.FocusY
+ }
+ }
+
+ processingMedia := &ProcessingMedia{
+ attachment: attachment,
+ data: data,
+ thumbstate: received,
+ fullSizeState: received,
+ database: m.db,
+ storage: m.storage,
+ }
+
+ return processingMedia, nil
+}
diff --git a/internal/media/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&#5_Z3J~sNXkH_~F~&6wXVLo-dM*ND_q!c0a98J&NkbbuNdfVq4T@8es3qLIPF4*RV@vYjR~NkiQcaBf1CIOY6lczE%fslmjMJKca_x3Hovtd4oPGhOOEwm63TFx@YJMn@21*MGk_60-8-jOi#@qytjxGrdrewet66!Mm74DuxtqQr!2@ku3iktxORbE7GKT~SCZWj0HL~$EQY1c5vYduZ_77Pqrh2Mfru93KcYyE$-QZfMzxBwrKLfJaHh!uXZYfSoTv_GFYYTENDGjZAZlWOc81g4%cPTg_fQ#-JzzS0NqO4nJ1jF95yJ42K8D@cd%%DCDZ~MaLJshEhnmEpSv_y@55M%OWGorW6PjOyC4r2!nLbRFSFdRdu2nUT~^o3OE7PIxt1#tZjjMT3uh9ZSzXxlUrmKO*&q@D-RNnhdNmrQk1T!uKQC^xX!~r2lh!~DhJv73j-8yPf5g8b&62A0tDhup0qCA7ITH3296H4ZvBLBKWwRZT6MgYbYdXASbx!A^9tCWPHHnBpP0SU#rht1gsC4xk@T9*Jd9BHNlquFfQ5NPU191lZ22pUu&G~nPmB$zW9K4vbOcYqV8cp$ShUOu92KW&K0fsRkckkSNgJNE3AJmBmMtr@TuADy-OK&^G7RNVe31nqD*bsvQI-~hcb*CnVuf!HMBFaxfEapVsAMTMf*qGg5PdzKUMDl#xSP*n9A@9%ip&eZmk-605hHeRHarPgUdQEqimpzsRZL0^eEoNCxx*5@zTXQo@*kbuh0c*mybPBEh1tKi&kJi76hRqqaZaP#tBsnO2rzYM!MHa$QZfs9Nd@VgOBo&7tvvUQtGhTVUr&ulSGG$d%*bXQjzyOfXmAtho-SH*2!1#9hY6dQ51eUtOMcKNA6-9OAI_gmPBRBuKRKJyW2A6Iyr0vbkp-87O7_lmst15jDf&0_M_smSAyvgL%n-q3K9AVpxLghgld9$iMfrB@d8N&33wmGDcXt@NXk~7^&LUuGeT15DauP&Bj0014LOg$vkm9U6P#PyL@m~Qtbr79ULoT^hSJBMZWWzjp$F-$%mY0EPghUAtYlAeq#^II4_fJNZmJGc%d-h1$Du^n4uGa@**0uMX_D@sb3Pcuzb3tQ0AhZcQNa0iMQc5DOV~yF6VJ8&qhnVXh$68pTO4qxrW&LNDbex%_LigONjXNPuPbsbbWUsUe1P^YbKjpy0JatODmHSNZ1IF~AA-uhc6q~n7UH@tATI%~qkdFO4ch6onp8F%&9-wjUAseD5xlG138m~5nMcTnE0qaHx25bOd~emMw3ZqSyRtxn~9~ACCd6sl4wOHuzQlALJZBo^yZzcx_lyKt0b^CDBME5nF81wwTN39Zk9qL*F%&&nBO5xq&uNomBszQsKcQJx*e2G99gK@IpzQk!1%Eoc!oV2iex@yfcl-z^_z!aS7aJDVm%r28o3LEDn-yQvv4i~r43_O4!LcNJIKvF63wNyhiM4EAOHmkjs#i^r3t1#7MjaU^@zLFhCMo1*5Y2d&jjZoqnSrEj^js#~TCWbKi#2xrZA4m&~u-!z~15aX4c8E7qhTle5-b-Yz@Q88i&Kn0Aaa$*sQg2jBbXTZg6XzD-2Pd__cg06AQ@zE#Obwi-CdtpvJW~g7SEaOz$88D0RQun4d~F7k5Yw*CstxYJseCm6d3r&NuzIh&awT2hWw^qPuCvIqHrx~l^RBkTGOVRPdrJtWqALQ@NIc%480&!02R92!vNEyOfm^M&4BoAGdpxo3Y^Pg0R6h2MX2_MHhnwBYvh20c72D$iU-UC5-$3S%yJQri6@OTXcz7HYRqas1XQrE3dlm%7MZY!pKXJ$TmDFzTlKoHS8JXdP*oFyG1Lze3P_jHgidsQpypp^w^*hf~EGXsD0I0@$IzFj3-_wKp%xRCy^O8oKs5kPJ~cT99zBBkl^Qm&PGp&YtI3Y5rP7#Y8qdqeD~3f45QVBAo-S!m-BIBfNCrTr~UJy_POWT!$sW~DZrH41aDfTlyhNyk2HI0Ks%vYs2ixpat%mjXcpaOd2O&WS1@kPE!8#eJDU5o4%VQUz@%f4ivTqk~zY4zBtUO2XRp_~RbbpqS@^yCetTy4_X8&#t1H~f406Z^*Nha-PTXl8cgXXxWpvkCf8V9$AiG%igkk2~WYF~$VLduZVGB-6F4eEjmuWc8JJnIx$k^dA-BFdzvhpc-FI26Cdr7ljwUZ*zhdNRwHqIlBLdWXYbocGAZxaFR#TeKi^1cB%1S*ayqup&6hKu_mBSqyWb7rLSPI3GQJWjVSr~UO*CH%vf@WKH~RBJBv_TRIFPz^yKA$DU^68*yoK4UusI3z9Ipq3zY7OSjSefJ4mMTzg~BKDEUhffP6nIFB%M!6^qfu5Wpp@coOGEcIuG#~tUf@VGQ6vPN8CY96ZHE6bnbJ@&!^Mm3Ouci3Yv*eA-MvqP9Mes$xGe-vjEc4^zUnM*iO$J5#Vdsj25*FyES#~AkVQszi9Lgd4OV2ztzSLdwLibs_TF@uQ!*KM7xS!&ty^6DCkUpSdtJP7bW!7mVPVYOTt^1~VI9d^&qetbkwkeIjNMZ4nzDl$4eIvDhE7vd@yNy7fgaLEfVuS1&_1P%LzuqRpr%t*oE*rCr1LQd*0Jpbh1f6-v48rHTe!tfcGFXGT9XQY^ZKH^qxEZ&uW38sgd-5R0KCjb2S*51-itFiWFM6ZyeQu$G2d3Q$j_~0fU8p8e-yMOzkofq!g&i-NPYgZu5#3DZLrb0~sXyY7sCEqaJ-IHfL!&hj~H5D4WYcHN%6rv$@Zl%3VuD5m!frKuZVEP~pL*&rZbtDD^YMp7q3Qo^WFzZRdT4QItNsHqe#rPTI7wJ0U8bj8YeqL5y&!pnKVnDUFmrP$3du8nZsT$M9YtVD^$pVdc##w^ksSYmSVd_Ff1$w&P4Bl3&t$4HZ682!oGc4Y&jVwkj$d%OlhotakyprCgCQebIp~$m&5k0Hu2lG~xf#t*n5~sGf@51_owF1c1PKunwMc%F%Qcjs01H1!mR*bEN-0O$UN3vznFkaMuoR5zF2#Ct~6aC*Xk*w9Tq&ngW_#airnTLt0WG8ReiMyA*s%*nD!7&u*4_CW3NbRoJVpIUCjh6&uV~1bxp4EjxmOZ_im~zwXvhCrjHVZ*paoaA6c686bgoUpz1$#uuEwxEQrRvjC@x^W$O%GE_P69--RR^ywwz01tTkeaHnp#zpY^McD-VzL78QLRr0Iy_770sa*i^e1f3x8$LsQo591foQ-!4lv~iVFE&@ee@8#oftHQsCxy7J&9g-9jZe!8xCTyM73p2LQnegsXlNcmgbLVY95WuE~y*sRLhVhR%VEheqEfdO^poi1Qhe0xK_1Q3L6B8p$@ew^9udr1eeVJ&edsOtUO*n2T!Fq9qsD3!sB@K8PxDj!$W~^JZpAm-5d2zt9ielNtuyv_V53OWWSypnr0z8hRuo$LKlpz~eMP~zLQ2sc!QxI-C_W-!&$&q#r6y8HvyLM0By0#*w&vfdRN_LsvmOXfpnRF3ipU@sWI5_-h9rP0mdRk#QIB6VeNOmRB&G31-1mUY-HYeMVCCuZovKf_FVi#z0dgKrVYnCHQc19VF@Fc%QYuQZegqVMwilt2cbYHtfg*11vttIC0Hr~Z_Z~#uNdkG#5#A^&&xMneHLH@MrU5H~v7Bh4qcvn@Gfw0wH&vWDD0r#BY@&4to5Bp4_rY0WL30!dXEIQU1zoz7k~psB-Ko8PADG4lY_gKXS%pDM!mR*9$$35rbS2Kgz2XoK_~_zzovOLa-V7eKN8B%4oxK278MI8MoES@_S$KHuJaMjmOGD2sXLMsmT2q1$C9MQh!qsilCaTSWzEURTOzf2LA&!Wr39k5Y11O!xjPcOjVUdf54I1vDS3cR0-*S$U~tLtBY_SwS#QcDGYIIGTODWUAuXwwzaFZkIcJ5~~&GEsx*fAwXqzTRNW~vlEj!VnUB5myRa9*mUOO^I871#J68E&dzp_HBeP-E@P9nzjC**j&wa6XCC8MvDBoXYDj~oqisy~RhpBDyU8PjV&Rdk4$uD-qD6J$jXK$rWgsqricjKATfBnhf5Td28UWQNF1IpFmu2GraHH5EnT$J#q&LH2xeiYLX6Hqw^W#*$@tk~fqOICUe7a_DraRdiUE$2TlXOmomz2QD_m0@WZ&zMrZg7!I38Eu5snvCcpa&&e8ZKJnnE*RY-_O#nIY#~4FI3-e5gn&@%jM7zW!eNx&&4g6U5v~jXpwgy^KS3UcgWyS5u0w#GC3cmPtQ9^QBgXMpHppwJ1zf0-mD@Qd8RPU^CXwG!&fBTCVS%zKa1-fDyG6*&s%tuqHz~ky2cC2S5H^Y#U9iY2COvvbDjWPo445bcSl6ku-R#yo7u3#Rx!Xl0^$nBcIEHW9NdP-_eqp%hHnFmAhLaR-P~Ox-nHd#DICdhSxuFi^LX*DtS4O2EJzgb6bi*6gq*LIJ4h^4FCpvI-RFUf779QsVZXNf%3QJO6aFK~9PX%%kkNKMP0Nc#Mf2^cu*46*ni!E*Okd8La%!JDpKs2$wx#0#kuFkEh#k1Cv5i*#6ww%KnH6oc4B$xa54Gr!!&kW$R_YVPajOjTuTQz2^Hg^MYE2NWEG4~AYs#JY4i1FJEL-kKXjg#zx0mdyX8KuBP^@#s!1#1-N*j_oB2TZEC*fTnA6pu&areSJpgOO~uQKAcyURV&$cp^SMe$*RGILWlth7S^It8Vyp3_xKq3yrbOKjfiyd$sQQFYv&4JJ1#kVCZ4Ihof9iZa@iG26eWkc!NrPwEjqO8*N1ZvriP%jGyX0hsSiT%xC1Vfy~3uyjDMFJiY*Vc9*gC-P^#Mcjm%2ohcvNhME6fx*4f9fThRijQb11J@MzqHpcXHYSibWJrEiBSouw$!98PoEfWfJs_xejA^TL7h%Yj$6GhLCvs30BvprqB18^OnH3J%w9IfY4HUR@6W_6EmEVNzGUy7@HeNFKIJy@6r*W6l!jslHoI*d8hpY4qpFQWWQFM09Ev6#rA7#VXfWiKFq!XfhSTYG~a~9Zjb6*4xbNIn9o&w9mM4zLouHO6a^1z*k7FyQ&EFguhgwLh7DC@6pAGVOw^bG#h4&aYqyzJX4XM%xPB8a3SdVZ-N-#d9doRj#bBAvUvydxcsDEeML8YRauL4q0p-JLT$n#t2oXT93Ge2b_52L67455~*bcH8aNG^7AqdGPO_adaGHgbP^3A8^5JGyiHf&PNQ^gbDNgvk1Z1JM8_e4jXGy~6Ja3PNIahqjbqreWUtb66GfLMr$HLwbfUAI@@boQsHQI9x4Z_Zal#^u-X-n_x-mjW~*LH-bs936MmqRvAuOcM~cXSNiZl4&8k9CN!gH_5hv*Ok6*ZQ28s2bq7*RE*zLw_TGJOBpKBqsDr!_YNHwRW8~K_XKitndhpo2P-hiMW9kftc2~WgJPyuoY&f6y*hjg-3aR&zA#eVlgE8#C4uDB7*oRx!o5$F@-AkAZeqENIW*QIu5LYSyhgOu&b30i7lERhtmJLRJgfQ90yiior7hIc_QYnyWNOX@Yd&2*wM6Tv3R%MfN^foK1QV4QOUWA_**rH6AxMAbrrB2$xgQPZl2X!x~7z4yOTsHm0uV~mwU3cOn@kxVNiaisdSAEYBl6JQ_DyXXpe%O!2ag#IxYOXWC*S#BURDR0JCHafpJ&4AWtpsxo4dGoaNZtp3J$Ch^m$~g#r2u$plnsU4ruc8HfF47V1mxllNnfyRqF3UX#~h_@mc$1DoVyPM9PH_7N9^Nz7n8bKrSrqpNyck@4Yv1I547vhzateMH!4W8%ugbqdN0&9A#nxwchqZN2qhYMEk#dD0WOug#yhvrc-f&c&4O7v8nmcvm6-3cOPl2P5PcDc6bQixVNpvp*4IALVWGaWk$rTRT8U2ZYWSkkL$BZD--&F%iIh5K0aROVIjCOhU@k0-@cXeYx&fMjw7Md#kBb6CYg#t9T@k3PQY7b7tJlse@qWlf~knivM3sx-II1T4r_VwF#_pJOYPYJqTav19hhg@pIIDxdMkZc3Ig0Fgr9B9VgpweaspyV5B5PfBb#p@8SGE$hP@g6TVjhh5!IIafDx$L0NzYOii$~ZRxxBfeS6uCnIq5vF!iiIgdVhkj-z-nXObVOa15P*S9$5yMKOUQK$H$zIENk30%m@n3svUY_NBnY!gtSCJ8E4IKGxlaFPdsxZ9Mhzzy%p!wz5nxr-zk7AaV@Mz#7rCRhTnsXCs5YFST7TWO~s_E1jVC^^%~G7xiN9UoH8@tCn#*dmbHXxiMuCZau_rFHehKN2Ke#!_V_khRSsxTdAK-!FcmPxa%pdjV~3U5H-nzR*CMzPBN#wxd03t9y%E~cx1~$5g!2JTv^Ahh$EraTDW&J92Oi~XKxKjuGEiPR^x8~sTo^yOqIy9&f#_m8cQrKQsSBaWSqN~!w#H5gipIx@QZqQJ_ALspwIlrhjaK%Y46iAgbze$J2x6M^HPuLJ^QztEalfY@uJz*o32WrJhX^A6A4i@SH!&XXxK7JIJE5NWaZOcPhzXxOFc!BsdAKJ32Yp!E1QQZLNeZNvAlOeN9Me!Fiq~-YnUUVjSYLn*J!DSDOO$t%cW#Rg4lzaGzK9ujasGv^pbaL%wyJmySxcyF1N^opMc%O!502A^eflQx5p~Oad*mQ9QbkSS0K60N4Hsfw&Qw7gpDKMpjW@Wz1ORA65ay_O9Msli&sQY9L#0$JTxD^Y~OX0axs&Wlf_xLTUy4t5%q0EG3Zj#q_NYV4wzh3^r!BK~XoClS~C18aYiHiPeY$-Y6YkF$lHz8sVp#so42T3YcP4tEI4-_FHVIwZ-g-x@p$YpCr6SwZU*A2J$2!$GHskFa3Z#Vh&m8RK1J_e96Q-Eq63vt7dGoZHKUT0Q!jq5Be_$UMK5Y$EDcaaGCXWaTH0EdZ-tj1nj_HhRTU$uH^hj_0GHaRE6yz9ftK%@@9@*Sn%*6z8G-yxjr8M1S5kwcK_HdZDzn-eMBJcG^h5qEGOo8DK8#H_7pUxvO_rUBoch0en15*NY!jHXM9wzLUVdqtmh!*L^KKR#8qrjJ%PxDgRB1BXyp$UDHofwmfMS!OzkfCt!bVfZ84d@nf#G&h7L!1S!@spMVXa^EuNv~f2H5Aisni6ZPJd-bb1*QHI_&Xxiy8X5ru74I2VeF*kSFE2j%!l7fDRNib4R_3q$sVHmlAjreB^4gp~kviNg5JB5Q5sAqA#JpsD#qom%QQr$FdR!CENmOojI&XaNd7rO76RL^dh3Eiq2CJiPc*3ACLYwh*KZMBbfN6VurMdOxTC#nfZPiTckaA8y68AszOd7YbPZpL7vPlx4rfs&mZjDcR88WV^DJUpHU71rp*Pw@SHbp$wG3K*m6nwnT8-ZO18qOY%nfL8&%E~2#EzYa$JEUhxLXN*aVbvcw920oO!VeTh2j1laX6v%XX8DLdRH_tHbpyh6xvHl76WseE#KB@P97o4&!uMkDdhK~raDnUOkmUG26#3@uZZV!jLcIHG^dASqH~mpThk&Y$PA6ZFuIa7S5YWrVCdDSb*kAo*BuyzEF&N&DD0F9B7z_P%npKtT7LQGdR4Dkl5MTo8Al37Qiv#VSfxuKop-GmTMz-ZMTPxf7-O8SSbUgyNkt8ZnBhx9oP3wNuqEB9cI-w%~3aZiUaVAvC#-v_N0u-V&FR6eaJMAV3PoZcTRK8CjMQiKhPDZ%XnY$3qR&C!OEK!JUebDgys80ZLZ5nWxRVgAXuK$f*I!#bYhXGzQ5kWLDa*j5%Fm_T8&Ux8n*msB^NhpPK@1avNLQKJa28PH%Xd39#*Y8v!s7IMAhFvkUFO3ly#2lmwNvIvFmRmF_@NVak6iMOmXs2kS$76BevXio&A8j@8tC&4UrYPOaHzn^XCAjct*_&VG-y^FqWp^5$s3A61gMkQ6!@-UqwESXe2utCOU~Ain%XdUQ^YWfcCgC^b#6Qs$IUn1gxIL72ckNbW5&yRRL9&%-GImO9wmrn8WrfVvZKiipmlfS0TlYTGbDwzJl7VFNn&4ytFjIt2S2v96q%Asey~dAkPD9nqt^UdlUCML#sNTNonjCjH$8BmcovVsT8Ag7vANkz-ww&SJY6~Hk5CexGxoIc!RoO^CV2rtBU~wS&PuKPX7iEs3Vepl*vuiST4u%y7ItR2o&y@%KZizVVdj7-o2U$peKyYZmM34S!lly#$bzSpzp%OC!tj0RTkR6UQq65u0#Mjo4VscxOPi0TCFeJnQhaM3tTIVB%@4Y6Xkb!9o6I1nuFpcWWbrO#0%B6Ov~GDHIosyv*q$Iy6ru6*fwj!ba8bCRgS%gCGTD%zdHS#Qp~zkfG*1wJiIi4u14i$Dd*si7!1VblHC5dJfRxMX_4&8f0&4WF3i1*orucNXS2WAQiKdL~3$U*FIm7Ky^XuIE^zpP$*OoGU2@q!yfHASsXTRQ2~^mQObTvNDJ*5TTMJB4R_YoMltN^x#-Fve85nG6a6-#zzGH@YADN1gU6zX8EglgznD2xRGv5gdxtIgNUWk89r~r!VBEA^MH2_N!~Q^p&hvWpsdkX$%mMQA9DZ*$uM5vzx89Pp^MjQalk*R-Bf!3uH4Nm#B@th~cuaVM&zOrsMuMgxt~v2Y#7oGhhoq7pKjJN@t@&5*V~rQCmV0DeY$mS1-1V$dtqXmVHMzHwct^eMODO^73B3NMJDJXGJ@%GQlEl~3f_P4l_$m401~w~wn4mPHJ%MKctx43vVlN2f@x3fFAPKBq0wSQ3MEp2~^#zUJjA%Msk-h3^CiGm%!^e5QklYqlwAVBtsWsI9sG%9jB30Ey$z-0H@13ngj!g$u6B66yx~Do7$v6g8N_fBIzZXh3$dvD9mt%brJso~AGiHtHgFS0JF*X&!9_vk5nJtOJaBQ-hHXiNAa9ooaBSX2EDOZP~3bIcUeVzk#Bi~9320M3_^4nRIPp45c*7Aqy@Jjws*!WGv8ha&Hw!tyJWmCWlH~DIg@HrJZnEqNnD%2Vu%!4mDULqpFBSotghLFqyZiLol7GmIs7qzj1jjakhgF^$MS%ia-FVp&~UJv9h_XhAIfsNslM_P4OVWqn9^o6VJdRZL@MYq~*cJovrVWPW0k0b4aCgWrGIT5Rn$ogfs*%OUi3&Ful_Rn#gh-U85ynsEe5OAVcVCiqWgL#SFuyR$xw&kl@yLagO-Ri2mp$~uG@mcLQ~wmQ2c5daujWV229Cyi-6Rq_&qL##FITesVeh18OqOHsDW!BHuclR%e0x2-%LYu8u8H8U833^jI2CKn*NJSAthYHffK@t&fEd~OQ&FSEgD9sGuf-#bkPJYqBXdGWR1vRX3Yhwssn_%qd~khls6ff#j@FDjtX2cBvd-UP8OcI52DG3~ZJ*a53HBT2AETacG5fODRpCEd#o5e_%10Yd^pu%gt&$9DG!k8SZSds8I*R!xr
diff --git a/internal/media/test/test-jpeg-blurhash.jpg b/internal/media/test/test-jpeg-blurhash.jpg
deleted file mode 100644
index 6b6ba472e..000000000
--- a/internal/media/test/test-jpeg-blurhash.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/test/test-with-exif.jpg b/internal/media/test/test-with-exif.jpg
deleted file mode 100644
index de56cd654..000000000
--- a/internal/media/test/test-with-exif.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/test/test-without-exif.jpg b/internal/media/test/test-without-exif.jpg
deleted file mode 100644
index 274188ee7..000000000
--- a/internal/media/test/test-without-exif.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/types.go b/internal/media/types.go
new file mode 100644
index 000000000..b9c79d464
--- /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 int
+
+const (
+ received processState = iota // processing order has been received but not done yet
+ complete // processing order has been completed successfully
+ errored // processing order has been completed with an error
+)
+
+// 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..bb9f4ecb5 100644
--- a/internal/processing/admin/emoji.go
+++ b/internal/processing/admin/emoji.go
@@ -19,55 +19,51 @@
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
+ if err == db.ErrAlreadyExists {
+ 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 := &gtsmodel.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..a22b926d6 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -215,62 +215,64 @@ func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab
// Used as profile avatar.
if a.AvatarMediaAttachmentID != "" {
if a.AvatarMediaAttachment == nil {
- avatar := &gtsmodel.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
}
- 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 := &gtsmodel.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
}
- 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..989bf4a1e 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -96,35 +96,36 @@ 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
}
- 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
}
- 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