From 6cd033449fd328410128bc3e840f4b8d3d74f052 Mon Sep 17 00:00:00 2001
From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 17 May 2021 19:06:58 +0200
Subject: Refine statuses (#26)
Remote media is now dereferenced and attached properly to incoming federated statuses.
Mentions are now dereferenced and attached properly to incoming federated statuses.
Small fixes to status visibility.
Allow URL params for filtering statuses:
// ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account.
// PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account.
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
// MediaOnlyKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
Add endpoint for fetching an account's statuses.
---
internal/media/handler.go | 317 +++++++++++++++++
internal/media/handler_test.go | 173 ++++++++++
internal/media/media.go | 507 ----------------------------
internal/media/media_test.go | 173 ----------
internal/media/mock_MediaHandler.go | 59 ----
internal/media/processicon.go | 141 ++++++++
internal/media/processimage.go | 128 +++++++
internal/media/processvideo.go | 23 ++
internal/media/test/test-jpeg-processed.jpg | Bin 300156 -> 771517 bytes
internal/media/test/test-jpeg-thumbnail.jpg | Bin 6790 -> 29611 bytes
internal/media/util.go | 8 +-
11 files changed, 788 insertions(+), 741 deletions(-)
create mode 100644 internal/media/handler.go
create mode 100644 internal/media/handler_test.go
delete mode 100644 internal/media/media.go
delete mode 100644 internal/media/media_test.go
delete mode 100644 internal/media/mock_MediaHandler.go
create mode 100644 internal/media/processicon.go
create mode 100644 internal/media/processimage.go
create mode 100644 internal/media/processvideo.go
(limited to 'internal/media')
diff --git a/internal/media/handler.go b/internal/media/handler.go
new file mode 100644
index 000000000..8bbff9c46
--- /dev/null
+++ b/internal/media/handler.go
@@ -0,0 +1,317 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package media
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+)
+
+// Size describes the *size* of a piece of media
+type Size string
+
+// Type describes the *type* of a piece of media
+type Type string
+
+const (
+ // Small is the key for small/thumbnail versions of media
+ Small Size = "small"
+ // Original is the key for original/fullsize versions of media and emoji
+ Original Size = "original"
+ // Static is the key for static (non-animated) versions of emoji
+ Static Size = "static"
+
+ // Attachment is the key for media attachments
+ Attachment Type = "attachment"
+ // Header is the key for profile header requests
+ Header Type = "header"
+ // Avatar is the key for profile avatar requests
+ Avatar Type = "avatar"
+ // Emoji is the key for emoji type requests
+ Emoji Type = "emoji"
+
+ // EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
+ EmojiMaxBytes = 51200
+)
+
+// 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(img []byte, accountID string, mediaType Type) (*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(attachment []byte, accountID string, remoteURL string) (*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(emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error)
+
+ // ProcessRemoteAttachment takes a transport, a bare-bones current attachment, and an accountID that the attachment belongs to.
+ // It then dereferences the attachment (ie., fetches the attachment bytes from the remote server), ensuring that the bytes are
+ // the correct content type. It stores the attachment in whatever storage backend the Handler has been initalized with, and returns
+ // information to the caller about the new attachment. It's the caller's responsibility to put the returned struct
+ // in the database.
+ ProcessRemoteAttachment(t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error)
+}
+
+type mediaHandler struct {
+ config *config.Config
+ db db.DB
+ storage storage.Storage
+ log *logrus.Logger
+}
+
+// New returns a new handler with the given config, db, storage, and logger
+func New(config *config.Config, database db.DB, storage storage.Storage, log *logrus.Logger) Handler {
+ return &mediaHandler{
+ config: config,
+ db: database,
+ storage: storage,
+ log: log,
+ }
+}
+
+/*
+ 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(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) {
+ l := mh.log.WithField("func", "SetHeaderForAccountID")
+
+ if mediaType != Header && mediaType != Avatar {
+ 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)
+ if err != nil {
+ return nil, fmt.Errorf("error processing %s: %s", mediaType, err)
+ }
+
+ // set it in the database
+ if err := mh.db.SetHeaderOrAvatarForAccountID(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(attachment []byte, accountID string, remoteURL string) (*gtsmodel.MediaAttachment, error) {
+ contentType, err := parseContentType(attachment)
+ if err != nil {
+ return nil, err
+ }
+ 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(attachment) == 0 {
+ return nil, errors.New("image was of size 0")
+ }
+ return mh.processImageAttachment(attachment, accountID, contentType, remoteURL)
+ 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(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 := >smodel.Account{}
+ if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil {
+ return nil, fmt.Errorf("error fetching instance account: %s", err)
+ }
+
+ // the file extension (either png or gif)
+ extension := strings.Split(contentType, "/")[1]
+
+ // create the urls and storage paths
+ URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
+
+ // generate a uuid for the new emoji -- normally we could let the database do this for us,
+ // but we need it below so we should create it here instead.
+ newEmojiID := uuid.NewString()
+
+ // webfinger uri for the emoji -- unrelated to actually serving the image
+ // will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c
+ emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID)
+
+ // serve url and storage path for the original emoji -- can be png or gif
+ emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
+ emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
+
+ // serve url and storage path for the static version -- will always be png
+ emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID)
+ emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID)
+
+ // store the original
+ if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ // store the static
+ if err := mh.storage.StoreFileAt(emojiStaticPath, static.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ // and finally return the new emoji data to the caller -- it's up to them what to do with it
+ e := >smodel.Emoji{
+ ID: newEmojiID,
+ Shortcode: shortcode,
+ Domain: "", // empty because this is a local emoji
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ ImageRemoteURL: "", // empty because this is a local emoji
+ ImageStaticRemoteURL: "", // empty because this is a local emoji
+ ImageURL: emojiURL,
+ ImageStaticURL: emojiStaticURL,
+ ImagePath: emojiPath,
+ ImageStaticPath: emojiStaticPath,
+ ImageContentType: contentType,
+ ImageStaticContentType: MIMEPng, // static version will always be a png
+ ImageFileSize: len(original.image),
+ ImageStaticFileSize: len(static.image),
+ ImageUpdatedAt: time.Now(),
+ Disabled: false,
+ URI: emojiURI,
+ VisibleInPicker: true,
+ CategoryID: "", // empty because this is a new emoji -- no category yet
+ }
+ return e, nil
+}
+
+func (mh *mediaHandler) ProcessRemoteAttachment(t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) {
+ 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(context.Background(), remoteIRI, expectedContentType)
+ if err != nil {
+ return nil, fmt.Errorf("dereferencing remote media with url %s: %s", remoteIRI.String(), err)
+ }
+
+ return mh.ProcessAttachment(attachmentBytes, accountID, currentAttachment.RemoteURL)
+}
diff --git a/internal/media/handler_test.go b/internal/media/handler_test.go
new file mode 100644
index 000000000..03dcdc21d
--- /dev/null
+++ b/internal/media/handler_test.go
@@ -0,0 +1,173 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package media
+
+import (
+ "context"
+ "io/ioutil"
+ "testing"
+
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/pg"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+)
+
+type MediaTestSuite struct {
+ suite.Suite
+ config *config.Config
+ log *logrus.Logger
+ db db.DB
+ mediaHandler *mediaHandler
+ mockStorage *storage.MockStorage
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *MediaTestSuite) SetupSuite() {
+ // some of our subsequent entities need a log so create this here
+ log := logrus.New()
+ log.SetLevel(logrus.TraceLevel)
+ suite.log = log
+
+ // Direct config to local postgres instance
+ c := config.Empty()
+ c.Protocol = "http"
+ c.Host = "localhost"
+ c.DBConfig = &config.DBConfig{
+ Type: "postgres",
+ Address: "localhost",
+ Port: 5432,
+ User: "postgres",
+ Password: "postgres",
+ Database: "postgres",
+ ApplicationName: "gotosocial",
+ }
+ c.MediaConfig = &config.MediaConfig{
+ MaxImageSize: 2 << 20,
+ }
+ c.StorageConfig = &config.StorageConfig{
+ Backend: "local",
+ BasePath: "/tmp",
+ ServeProtocol: "http",
+ ServeHost: "localhost",
+ ServeBasePath: "/fileserver/media",
+ }
+ suite.config = c
+ // use an actual database for this, because it's just easier than mocking one out
+ database, err := pg.NewPostgresService(context.Background(), c, log)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.db = database
+
+ suite.mockStorage = &storage.MockStorage{}
+ // We don't need storage to do anything for these tests, so just simulate a success and do nothing
+ suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
+
+ // and finally here's the thing we're actually testing!
+ suite.mediaHandler = &mediaHandler{
+ config: suite.config,
+ db: suite.db,
+ storage: suite.mockStorage,
+ log: log,
+ }
+}
+
+func (suite *MediaTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+// SetupTest creates a db connection and creates necessary tables before each test
+func (suite *MediaTestSuite) SetupTest() {
+ // create all the tables we might need in thie suite
+ models := []interface{}{
+ >smodel.Account{},
+ >smodel.MediaAttachment{},
+ }
+ for _, m := range models {
+ if err := suite.db.CreateTable(m); err != nil {
+ logrus.Panicf("db connection error: %s", err)
+ }
+ }
+
+ err := suite.db.CreateInstanceAccount()
+ if err != nil {
+ logrus.Panic(err)
+ }
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *MediaTestSuite) TearDownTest() {
+
+ // remove all the tables we might have used so it's clear for the next test
+ models := []interface{}{
+ >smodel.Account{},
+ >smodel.MediaAttachment{},
+ }
+ for _, m := range models {
+ if err := suite.db.DropTable(m); err != nil {
+ logrus.Panicf("error dropping table: %s", err)
+ }
+ }
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *MediaTestSuite) TestSetHeaderOrAvatarForAccountID() {
+ // load test image
+ f, err := ioutil.ReadFile("./test/test-jpeg.jpg")
+ assert.Nil(suite.T(), err)
+
+ ma, err := suite.mediaHandler.ProcessHeaderOrAvatar(f, "weeeeeee", "header")
+ assert.Nil(suite.T(), err)
+ suite.log.Debugf("%+v", ma)
+
+ // attachment should have....
+ assert.Equal(suite.T(), "weeeeeee", ma.AccountID)
+ assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", ma.Blurhash)
+ //TODO: add more checks here, cba right now!
+}
+
+func (suite *MediaTestSuite) TestProcessLocalEmoji() {
+ f, err := ioutil.ReadFile("./test/rainbow-original.png")
+ assert.NoError(suite.T(), err)
+
+ emoji, err := suite.mediaHandler.ProcessLocalEmoji(f, "rainbow")
+ assert.NoError(suite.T(), err)
+ suite.log.Debugf("%+v", emoji)
+}
+
+// TODO: add tests for sad path, gif, png....
+
+func TestMediaTestSuite(t *testing.T) {
+ suite.Run(t, new(MediaTestSuite))
+}
diff --git a/internal/media/media.go b/internal/media/media.go
deleted file mode 100644
index 84f4ef554..000000000
--- a/internal/media/media.go
+++ /dev/null
@@ -1,507 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package media
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
-)
-
-// Size describes the *size* of a piece of media
-type Size string
-
-// Type describes the *type* of a piece of media
-type Type string
-
-const (
- // Small is the key for small/thumbnail versions of media
- Small Size = "small"
- // Original is the key for original/fullsize versions of media and emoji
- Original Size = "original"
- // Static is the key for static (non-animated) versions of emoji
- Static Size = "static"
-
- // Attachment is the key for media attachments
- Attachment Type = "attachment"
- // Header is the key for profile header requests
- Header Type = "header"
- // Avatar is the key for profile avatar requests
- Avatar Type = "avatar"
- // Emoji is the key for emoji type requests
- Emoji Type = "emoji"
-
- // EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
- EmojiMaxBytes = 51200
-)
-
-// 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(img []byte, accountID string, mediaType Type) (*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.
- ProcessLocalAttachment(attachment []byte, accountID string) (*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(emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error)
-}
-
-type mediaHandler struct {
- config *config.Config
- db db.DB
- storage storage.Storage
- log *logrus.Logger
-}
-
-// New returns a new handler with the given config, db, storage, and logger
-func New(config *config.Config, database db.DB, storage storage.Storage, log *logrus.Logger) Handler {
- return &mediaHandler{
- config: config,
- db: database,
- storage: storage,
- log: log,
- }
-}
-
-/*
- 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(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) {
- l := mh.log.WithField("func", "SetHeaderForAccountID")
-
- if mediaType != Header && mediaType != Avatar {
- 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)
- if err != nil {
- return nil, fmt.Errorf("error processing %s: %s", mediaType, err)
- }
-
- // set it in the database
- if err := mh.db.SetHeaderOrAvatarForAccountID(ma, accountID); err != nil {
- return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err)
- }
-
- return ma, nil
-}
-
-// 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.
-func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID string) (*gtsmodel.MediaAttachment, error) {
- contentType, err := parseContentType(attachment)
- if err != nil {
- return nil, err
- }
- 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")
- }
- if len(attachment) > mh.config.MediaConfig.MaxVideoSize {
- return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize)
- }
- return mh.processVideoAttachment(attachment, accountID, contentType)
- case MIMEImage:
- if !SupportedImageType(contentType) {
- return nil, fmt.Errorf("image type %s not supported", contentType)
- }
- if len(attachment) == 0 {
- return nil, errors.New("image was of size 0")
- }
- if len(attachment) > mh.config.MediaConfig.MaxImageSize {
- return nil, fmt.Errorf("image size %d bytes exceeded max image size of %d bytes", len(attachment), mh.config.MediaConfig.MaxImageSize)
- }
- return mh.processImageAttachment(attachment, accountID, contentType)
- 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(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 := >smodel.Account{}
- if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil {
- return nil, fmt.Errorf("error fetching instance account: %s", err)
- }
-
- // the file extension (either png or gif)
- extension := strings.Split(contentType, "/")[1]
-
- // create the urls and storage paths
- URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
-
- // generate a uuid for the new emoji -- normally we could let the database do this for us,
- // but we need it below so we should create it here instead.
- newEmojiID := uuid.NewString()
-
- // webfinger uri for the emoji -- unrelated to actually serving the image
- // will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c
- emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID)
-
- // serve url and storage path for the original emoji -- can be png or gif
- emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
- emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
-
- // serve url and storage path for the static version -- will always be png
- emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID)
- emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID)
-
- // store the original
- if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // store the static
- if err := mh.storage.StoreFileAt(emojiStaticPath, static.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and finally return the new emoji data to the caller -- it's up to them what to do with it
- e := >smodel.Emoji{
- ID: newEmojiID,
- Shortcode: shortcode,
- Domain: "", // empty because this is a local emoji
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- ImageRemoteURL: "", // empty because this is a local emoji
- ImageStaticRemoteURL: "", // empty because this is a local emoji
- ImageURL: emojiURL,
- ImageStaticURL: emojiStaticURL,
- ImagePath: emojiPath,
- ImageStaticPath: emojiStaticPath,
- ImageContentType: contentType,
- ImageStaticContentType: MIMEPng, // static version will always be a png
- ImageFileSize: len(original.image),
- ImageStaticFileSize: len(static.image),
- ImageUpdatedAt: time.Now(),
- Disabled: false,
- URI: emojiURI,
- VisibleInPicker: true,
- CategoryID: "", // empty because this is a new emoji -- no category yet
- }
- return e, nil
-}
-
-/*
- HELPER FUNCTIONS
-*/
-
-func (mh *mediaHandler) processVideoAttachment(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) {
- return nil, nil
-}
-
-func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) {
- var clean []byte
- var err error
- var original *imageAndMeta
- var small *imageAndMeta
-
- 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, 256, 256)
- if err != nil {
- return nil, fmt.Errorf("error deriving thumbnail: %s", err)
- }
-
- // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it
- extension := strings.Split(contentType, "/")[1]
- newMediaID := uuid.NewString()
-
- URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
- originalURL := fmt.Sprintf("%s/%s/attachment/original/%s.%s", URLbase, accountID, newMediaID, extension)
- smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg
-
- // we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension)
- if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg
- if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- ma := >smodel.MediaAttachment{
- ID: newMediaID,
- StatusID: "",
- URL: originalURL,
- 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: original.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: "",
- },
- Avatar: false,
- Header: false,
- }
-
- return ma, nil
-
-}
-
-func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) {
- var isHeader bool
- var isAvatar bool
-
- switch mediaType {
- case Header:
- isHeader = true
- case Avatar:
- 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 uuid for the name of the file so we don't store any unnecessary info about it
- extension := strings.Split(contentType, "/")[1]
- newMediaID := uuid.NewString()
-
- URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
- originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
- smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
-
- // we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension)
- if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension)
- if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- ma := >smodel.MediaAttachment{
- ID: newMediaID,
- StatusID: "",
- URL: originalURL,
- 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: original.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/media_test.go b/internal/media/media_test.go
deleted file mode 100644
index 03dcdc21d..000000000
--- a/internal/media/media_test.go
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package media
-
-import (
- "context"
- "io/ioutil"
- "testing"
-
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/pg"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
-)
-
-type MediaTestSuite struct {
- suite.Suite
- config *config.Config
- log *logrus.Logger
- db db.DB
- mediaHandler *mediaHandler
- mockStorage *storage.MockStorage
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *MediaTestSuite) SetupSuite() {
- // some of our subsequent entities need a log so create this here
- log := logrus.New()
- log.SetLevel(logrus.TraceLevel)
- suite.log = log
-
- // Direct config to local postgres instance
- c := config.Empty()
- c.Protocol = "http"
- c.Host = "localhost"
- c.DBConfig = &config.DBConfig{
- Type: "postgres",
- Address: "localhost",
- Port: 5432,
- User: "postgres",
- Password: "postgres",
- Database: "postgres",
- ApplicationName: "gotosocial",
- }
- c.MediaConfig = &config.MediaConfig{
- MaxImageSize: 2 << 20,
- }
- c.StorageConfig = &config.StorageConfig{
- Backend: "local",
- BasePath: "/tmp",
- ServeProtocol: "http",
- ServeHost: "localhost",
- ServeBasePath: "/fileserver/media",
- }
- suite.config = c
- // use an actual database for this, because it's just easier than mocking one out
- database, err := pg.NewPostgresService(context.Background(), c, log)
- if err != nil {
- suite.FailNow(err.Error())
- }
- suite.db = database
-
- suite.mockStorage = &storage.MockStorage{}
- // We don't need storage to do anything for these tests, so just simulate a success and do nothing
- suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
-
- // and finally here's the thing we're actually testing!
- suite.mediaHandler = &mediaHandler{
- config: suite.config,
- db: suite.db,
- storage: suite.mockStorage,
- log: log,
- }
-}
-
-func (suite *MediaTestSuite) TearDownSuite() {
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *MediaTestSuite) SetupTest() {
- // create all the tables we might need in thie suite
- models := []interface{}{
- >smodel.Account{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.CreateTable(m); err != nil {
- logrus.Panicf("db connection error: %s", err)
- }
- }
-
- err := suite.db.CreateInstanceAccount()
- if err != nil {
- logrus.Panic(err)
- }
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *MediaTestSuite) TearDownTest() {
-
- // remove all the tables we might have used so it's clear for the next test
- models := []interface{}{
- >smodel.Account{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.DropTable(m); err != nil {
- logrus.Panicf("error dropping table: %s", err)
- }
- }
-}
-
-/*
- ACTUAL TESTS
-*/
-
-func (suite *MediaTestSuite) TestSetHeaderOrAvatarForAccountID() {
- // load test image
- f, err := ioutil.ReadFile("./test/test-jpeg.jpg")
- assert.Nil(suite.T(), err)
-
- ma, err := suite.mediaHandler.ProcessHeaderOrAvatar(f, "weeeeeee", "header")
- assert.Nil(suite.T(), err)
- suite.log.Debugf("%+v", ma)
-
- // attachment should have....
- assert.Equal(suite.T(), "weeeeeee", ma.AccountID)
- assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", ma.Blurhash)
- //TODO: add more checks here, cba right now!
-}
-
-func (suite *MediaTestSuite) TestProcessLocalEmoji() {
- f, err := ioutil.ReadFile("./test/rainbow-original.png")
- assert.NoError(suite.T(), err)
-
- emoji, err := suite.mediaHandler.ProcessLocalEmoji(f, "rainbow")
- assert.NoError(suite.T(), err)
- suite.log.Debugf("%+v", emoji)
-}
-
-// TODO: add tests for sad path, gif, png....
-
-func TestMediaTestSuite(t *testing.T) {
- suite.Run(t, new(MediaTestSuite))
-}
diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go
deleted file mode 100644
index 10fffbba4..000000000
--- a/internal/media/mock_MediaHandler.go
+++ /dev/null
@@ -1,59 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package media
-
-import (
- mock "github.com/stretchr/testify/mock"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
-)
-
-// MockMediaHandler is an autogenerated mock type for the MediaHandler type
-type MockMediaHandler struct {
- mock.Mock
-}
-
-// ProcessAttachment provides a mock function with given fields: img, accountID
-func (_m *MockMediaHandler) ProcessAttachment(img []byte, accountID string) (*gtsmodel.MediaAttachment, error) {
- ret := _m.Called(img, accountID)
-
- var r0 *gtsmodel.MediaAttachment
- if rf, ok := ret.Get(0).(func([]byte, string) *gtsmodel.MediaAttachment); ok {
- r0 = rf(img, accountID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*gtsmodel.MediaAttachment)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func([]byte, string) error); ok {
- r1 = rf(img, accountID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// SetHeaderOrAvatarForAccountID provides a mock function with given fields: img, accountID, headerOrAvi
-func (_m *MockMediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) {
- ret := _m.Called(img, accountID, headerOrAvi)
-
- var r0 *gtsmodel.MediaAttachment
- if rf, ok := ret.Get(0).(func([]byte, string, string) *gtsmodel.MediaAttachment); ok {
- r0 = rf(img, accountID, headerOrAvi)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*gtsmodel.MediaAttachment)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func([]byte, string, string) error); ok {
- r1 = rf(img, accountID, headerOrAvi)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
diff --git a/internal/media/processicon.go b/internal/media/processicon.go
new file mode 100644
index 000000000..962d1c6d8
--- /dev/null
+++ b/internal/media/processicon.go
@@ -0,0 +1,141 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package media
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) {
+ var isHeader bool
+ var isAvatar bool
+
+ switch mediaType {
+ case Header:
+ isHeader = true
+ case Avatar:
+ 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 uuid for the name of the file so we don't store any unnecessary info about it
+ extension := strings.Split(contentType, "/")[1]
+ newMediaID := uuid.NewString()
+
+ URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
+ originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
+ smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
+
+ // we store the original...
+ originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension)
+ if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ // and a thumbnail...
+ smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension)
+ if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ ma := >smodel.MediaAttachment{
+ ID: newMediaID,
+ StatusID: "",
+ URL: originalURL,
+ 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: original.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
new file mode 100644
index 000000000..dd8bff02c
--- /dev/null
+++ b/internal/media/processimage.go
@@ -0,0 +1,128 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package media
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, contentType string, remoteURL string) (*gtsmodel.MediaAttachment, error) {
+ var clean []byte
+ var err error
+ var original *imageAndMeta
+ var small *imageAndMeta
+
+ 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, 256, 256)
+ if err != nil {
+ return nil, fmt.Errorf("error deriving thumbnail: %s", err)
+ }
+
+ // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it
+ extension := strings.Split(contentType, "/")[1]
+ newMediaID := uuid.NewString()
+
+ URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
+ originalURL := fmt.Sprintf("%s/%s/attachment/original/%s.%s", URLbase, accountID, newMediaID, extension)
+ smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg
+
+ // we store the original...
+ originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension)
+ if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ // and a thumbnail...
+ smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg
+ if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
+ return nil, fmt.Errorf("storage error: %s", err)
+ }
+
+ ma := >smodel.MediaAttachment{
+ ID: newMediaID,
+ StatusID: "",
+ URL: originalURL,
+ RemoteURL: remoteURL,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Type: gtsmodel.FileTypeImage,
+ FileMeta: gtsmodel.FileMeta{
+ Original: gtsmodel.Original{
+ Width: original.width,
+ Height: original.height,
+ Size: original.size,
+ Aspect: original.aspect,
+ },
+ Small: gtsmodel.Small{
+ Width: small.width,
+ Height: small.height,
+ Size: small.size,
+ Aspect: small.aspect,
+ },
+ },
+ AccountID: accountID,
+ Description: "",
+ ScheduledStatusID: "",
+ Blurhash: original.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: "",
+ },
+ Avatar: false,
+ Header: false,
+ }
+
+ return ma, nil
+
+}
diff --git a/internal/media/processvideo.go b/internal/media/processvideo.go
new file mode 100644
index 000000000..a2debf648
--- /dev/null
+++ b/internal/media/processvideo.go
@@ -0,0 +1,23 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package media
+
+// 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-jpeg-processed.jpg b/internal/media/test/test-jpeg-processed.jpg
index 81dab59c7..33c75ac4a 100644
Binary files a/internal/media/test/test-jpeg-processed.jpg and b/internal/media/test/test-jpeg-processed.jpg differ
diff --git a/internal/media/test/test-jpeg-thumbnail.jpg b/internal/media/test/test-jpeg-thumbnail.jpg
index b419a86dd..b87b2eb79 100644
Binary files a/internal/media/test/test-jpeg-thumbnail.jpg and b/internal/media/test/test-jpeg-thumbnail.jpg differ
diff --git a/internal/media/util.go b/internal/media/util.go
index f4f2819af..1178649ea 100644
--- a/internal/media/util.go
+++ b/internal/media/util.go
@@ -206,7 +206,9 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {
}
out := &bytes.Buffer{}
- if err := jpeg.Encode(out, i, nil); err != nil {
+ if err := jpeg.Encode(out, i, &jpeg.Options{
+ Quality: 100,
+ }); err != nil {
return nil, err
}
@@ -256,7 +258,9 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet
aspect := float64(width) / float64(height)
out := &bytes.Buffer{}
- if err := jpeg.Encode(out, thumb, nil); err != nil {
+ if err := jpeg.Encode(out, thumb, &jpeg.Options{
+ Quality: 100,
+ }); err != nil {
return nil, err
}
return &imageAndMeta{
--
cgit v1.3