diff --git a/go.mod b/go.mod index 51e61a9b4..25a611a14 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/h2non/filetype v1.1.1 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/onsi/ginkgo v1.15.0 // indirect github.com/onsi/gomega v1.10.5 // indirect github.com/scottleedavis/go-exif-remove v0.0.0-20190908021517-58bdbaac8636 diff --git a/go.sum b/go.sum index 2cb769b82..1b7c5bcc1 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= diff --git a/internal/db/model/mediaattachment.go b/internal/db/model/mediaattachment.go new file mode 100644 index 000000000..5ce5c113b --- /dev/null +++ b/internal/db/model/mediaattachment.go @@ -0,0 +1,234 @@ +/* + 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 model + +import ( + "time" +) + +// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is +// somewhere in storage and that can be retrieved and served by the router. +type MediaAttachment struct { + // ID of the attachment in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // ID of the status to which this is attached + StatusID string + // Where can the attachment be retrieved on a remote server + RemoteURL string + // When was the attachment created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was the attachment last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Type of file (image/gif/audio/video) + Type FileType `pg:",notnull"` + // Metadata about the file + FileMeta FileMeta + // To which account does this attachment belong + AccountID string `pg:",notnull"` + // Description of the attachment (for screenreaders) + Description string + // To which scheduled status does this attachment belong + ScheduledStatusID string + // What is the generated blurhash of this attachment + Blurhash string + // What is the processing status of this attachment + Processing ProcessingStatus + // metadata for the whole file + File + // small image thumbnail derived from a larger image, video, or audio file. + Thumbnail + // Is this attachment being used as an avatar? + Avatar bool + // Is this attachment being used as a header? + Header bool +} + +// File refers to the metadata for the whole file +type File struct { + // What is the path of the file in storage. + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes. + FileSize int + // When was the file last updated. + UpdatedAt time.Time `pg:"type:timestamp,default:now()"` +} + +// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. +type Thumbnail struct { + // What is the path of the file in storage + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes + FileSize int + // When was the file last updated + UpdatedAt time.Time `pg:"type:timestamp,default:now()"` + // What is the remote URL of the thumbnail + RemoteURL string +} + +// ProcessingStatus refers to how far along in the processing stage the attachment is. +type ProcessingStatus int + +const ( + // ProcessingStatusReceived: the attachment has been received and is awaiting processing. No thumbnail available yet. + ProcessingStatusReceived ProcessingStatus = 0 + // ProcessingStatusProcessing: the attachment is currently being processed. Thumbnail is available but full media is not. + ProcessingStatusProcessing ProcessingStatus = 1 + // ProcessingStatusProcessed: the attachment has been fully processed and is ready to be served. + ProcessingStatusProcessed ProcessingStatus = 2 + // ProcessingStatusError: something went wrong processing the attachment and it won't be tried again--these can be deleted. + ProcessingStatusError ProcessingStatus = 666 +) + +// FileType refers to the file type of the media attaachment. +type FileType string + +const ( + // FileTypeImage is for jpegs and pngs + FileTypeImage FileType = "image" + // FileTypeGif is for native gifs and soundless videos that have been converted to gifs + FileTypeGif FileType = "gif" + // FileTypeAudio is for audio-only files (no video) + FileTypeAudio FileType = "audio" + // FileTypeVideo is for files with audio + visual + FileTypeVideo FileType = "video" +) + +/* + FILEMETA INTERFACES +*/ + +// FileMeta describes metadata about the actual contents of the file. +type FileMeta interface { + GetOriginal() OriginalMeta + GetSmall() SmallMeta +} + +// OriginalMeta contains info about the originally submitted media +type OriginalMeta interface { + // GetWidth gets the width of a video or image or gif in pixels. + GetWidth() int + // GetHeight gets the height of a video or image or gif in pixels. + GetHeight() int + // GetSize gets the total area of a video or image or gif in pixels (width * height). + GetSize() int + // GetAspect gets the aspect ratio of a video or image or gif in pixels (width / height). + GetAspect() float64 + // GetFrameRate gets the FPS of a video or gif. + GetFrameRate() float64 + // GetDuration gets the length in seconds of a video or gif or audio file. + GetDuration() float64 + // GetBitrate gets the bits per second of a video, gif, or audio file. + GetBitrate() float64 +} + +// SmallMeta contains info about the derived thumbnail for the submitted media +type SmallMeta interface { + // GetWidth gets the width of a video or image or gif in pixels. + GetWidth() int + // GetHeight gets the height of a video or image or gif in pixels. + GetHeight() int + // GetSize gets the total area of a video or image or gif in pixels (width * height). + GetSize() int + // GetAspect gets the aspect ratio of a video or image or gif in pixels (width / height). + GetAspect() float64 +} + +/* + FILE META IMPLEMENTATIONS +*/ + +// Small implements SmallMeta and can be used for a thumbnail of any media type +type Small struct { + Width int + Height int + Size int + Aspect float64 +} + +func (s Small) GetWidth() int { + return s.Width +} + +func (s Small) GetHeight() int { + return s.Height +} + +func (s Small) GetSize() int { + return s.Height * s.Width +} + +func (s Small) GetAspect() float64 { + return float64(s.Width) / float64(s.Height) +} + +// STILL IMAGES + +// ImageFileMeta implements FileMeta for still images. +type ImageFileMeta struct { + Original ImageOriginal + Small Small +} + +func (m ImageFileMeta) GetOriginal() OriginalMeta { + return m.Original +} + +func (m ImageFileMeta) GetSmall() SmallMeta { + return m.Small +} + +// ImageOriginal implements OriginalMeta for still images +type ImageOriginal struct { + Width int + Height int + Size int + Aspect float64 +} + +func (o ImageOriginal) GetWidth() int { + return o.Width +} + +func (o ImageOriginal) GetHeight() int { + return o.Height +} + +func (o ImageOriginal) GetSize() int { + return o.Height * o.Width +} + +func (o ImageOriginal) GetAspect() float64 { + return float64(o.Width) / float64(o.Height) +} + +func (o ImageOriginal) GetFrameRate() float64 { + return 0 +} + +func (o ImageOriginal) GetDuration() float64 { + return 0 +} + +func (o ImageOriginal) GetBitrate() float64 { + return 0 +} diff --git a/internal/db/model/mock_FileMeta.go b/internal/db/model/mock_FileMeta.go new file mode 100644 index 000000000..3612460be --- /dev/null +++ b/internal/db/model/mock_FileMeta.go @@ -0,0 +1,42 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package model + +import mock "github.com/stretchr/testify/mock" + +// MockFileMeta is an autogenerated mock type for the FileMeta type +type MockFileMeta struct { + mock.Mock +} + +// GetOriginal provides a mock function with given fields: +func (_m *MockFileMeta) GetOriginal() OriginalMeta { + ret := _m.Called() + + var r0 OriginalMeta + if rf, ok := ret.Get(0).(func() OriginalMeta); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(OriginalMeta) + } + } + + return r0 +} + +// GetSmall provides a mock function with given fields: +func (_m *MockFileMeta) GetSmall() SmallMeta { + ret := _m.Called() + + var r0 SmallMeta + if rf, ok := ret.Get(0).(func() SmallMeta); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(SmallMeta) + } + } + + return r0 +} diff --git a/internal/db/model/mock_OriginalMeta.go b/internal/db/model/mock_OriginalMeta.go new file mode 100644 index 000000000..7c3b8d59b --- /dev/null +++ b/internal/db/model/mock_OriginalMeta.go @@ -0,0 +1,108 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package model + +import mock "github.com/stretchr/testify/mock" + +// MockOriginalMeta is an autogenerated mock type for the OriginalMeta type +type MockOriginalMeta struct { + mock.Mock +} + +// GetAspect provides a mock function with given fields: +func (_m *MockOriginalMeta) GetAspect() float64 { + ret := _m.Called() + + var r0 float64 + if rf, ok := ret.Get(0).(func() float64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// GetBitrate provides a mock function with given fields: +func (_m *MockOriginalMeta) GetBitrate() float64 { + ret := _m.Called() + + var r0 float64 + if rf, ok := ret.Get(0).(func() float64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// GetDuration provides a mock function with given fields: +func (_m *MockOriginalMeta) GetDuration() float64 { + ret := _m.Called() + + var r0 float64 + if rf, ok := ret.Get(0).(func() float64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// GetFrameRate provides a mock function with given fields: +func (_m *MockOriginalMeta) GetFrameRate() float64 { + ret := _m.Called() + + var r0 float64 + if rf, ok := ret.Get(0).(func() float64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// GetHeight provides a mock function with given fields: +func (_m *MockOriginalMeta) GetHeight() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetSize provides a mock function with given fields: +func (_m *MockOriginalMeta) GetSize() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetWidth provides a mock function with given fields: +func (_m *MockOriginalMeta) GetWidth() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} diff --git a/internal/db/model/mock_SmallMeta.go b/internal/db/model/mock_SmallMeta.go new file mode 100644 index 000000000..1138ba11c --- /dev/null +++ b/internal/db/model/mock_SmallMeta.go @@ -0,0 +1,66 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package model + +import mock "github.com/stretchr/testify/mock" + +// MockSmallMeta is an autogenerated mock type for the SmallMeta type +type MockSmallMeta struct { + mock.Mock +} + +// GetAspect provides a mock function with given fields: +func (_m *MockSmallMeta) GetAspect() float64 { + ret := _m.Called() + + var r0 float64 + if rf, ok := ret.Get(0).(func() float64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// GetHeight provides a mock function with given fields: +func (_m *MockSmallMeta) GetHeight() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetSize provides a mock function with given fields: +func (_m *MockSmallMeta) GetSize() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetWidth provides a mock function with given fields: +func (_m *MockSmallMeta) GetWidth() int { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} diff --git a/internal/media/media.go b/internal/media/media.go index d3ab2b217..dc82f4de9 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -21,31 +21,22 @@ package media import ( "errors" "fmt" - "mime/multipart" + "io" "github.com/google/uuid" - "github.com/h2non/filetype" - exifremove "github.com/scottleedavis/go-exif-remove" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/model" "github.com/superseriousbusiness/gotosocial/internal/storage" ) -var ( - acceptedImageTypes = []string{ - "jpeg", - "gif", - "png", - } -) - // MediaHandler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs. type MediaHandler interface { - // SetHeaderForAccountID takes a new header image for an account, checks it out, removes exif data from it, + // SetHeaderOrAvatarForAccountID 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's web location. - SetHeaderForAccountID(f multipart.File, id string) (*HeaderInfo, error) + // and then returns information to the caller about the new header. + SetHeaderOrAvatarForAccountID(f io.Reader, accountID string, headerOrAvi string) (*model.MediaAttachment, error) } type mediaHandler struct { @@ -72,14 +63,24 @@ type HeaderInfo struct { HeaderStatic string } -func (mh *mediaHandler) SetHeaderForAccountID(f multipart.File, accountID string) (*HeaderInfo, error) { +/* + INTERFACE FUNCTIONS +*/ +func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(f io.Reader, accountID string, headerOrAvi string) (*model.MediaAttachment, error) { l := mh.log.WithField("func", "SetHeaderForAccountID") - // make sure we can handle this - extension, err := processableHeaderOrAvi(f) + if headerOrAvi != "header" && headerOrAvi != "avatar" { + return nil, errors.New("header or avatar not selected") + } + + // make sure we have an image we can handle + contentType, err := parseContentType(f) if err != nil { return nil, err } + if !supportedImageType(contentType) { + return nil, fmt.Errorf("%s is not an accepted image type", contentType) + } // extract the bytes imageBytes := []byte{} @@ -89,64 +90,97 @@ func (mh *mediaHandler) SetHeaderForAccountID(f multipart.File, accountID string } l.Tracef("read %d bytes of file", size) - // close the open file--we don't need it anymore now we have the bytes - if err := f.Close(); err != nil { - return nil, fmt.Errorf("error closing file: %s", err) + // // close the open file--we don't need it anymore now we have the bytes + // if err := f.Close(); err != nil { + // return nil, fmt.Errorf("error closing file: %s", err) + // } + + // process it + return mh.processHeaderOrAvi(imageBytes, contentType, headerOrAvi, accountID) +} + +/* + HELPER FUNCTIONS +*/ + +func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*model.MediaAttachment, error) { + if headerOrAvi != "header" && headerOrAvi != "avatar" { + return nil, errors.New("header or avatar not selected") } - // remove exif data from images because fuck that shit - cleanBytes := []byte{} - if extension == "jpeg" || extension == "png" { - cleanBytes, err = exifremove.Remove(imageBytes) - if err != nil { - return nil, fmt.Errorf("error removing exif from image: %s", err) + clean := []byte{} + var err error + + switch contentType { + case "image/jpeg": + if clean, err = purgeExif(imageBytes); err != nil { + return nil, fmt.Errorf("error cleaning exif data: %s", err) } - } else { - // our only other accepted image type (gif) doesn't need cleaning - cleanBytes = imageBytes + case "image/png": + if clean, err = purgeExif(imageBytes); err != nil { + return nil, fmt.Errorf("error cleaning exif data: %s", err) + } + case "image/gif": + clean = imageBytes + } + + original, err := deriveImage(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error parsing image: %s", err) + } + + small, err := deriveThumbnail(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error deriving thumbnail: %s", err) } // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it - path := fmt.Sprintf("/%s/media/headers/%s.%s", accountID, uuid.NewString(), extension) - if err := mh.storage.StoreFileAt(path, cleanBytes); err != nil { + newMediaID := uuid.NewString() + originalPath := fmt.Sprintf("/%s/media/%s/original/%s.%s", accountID, headerOrAvi, newMediaID, contentType) + if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + smallPath := fmt.Sprintf("/%s/media/%s/small/%s.%s", accountID, headerOrAvi, newMediaID, contentType) + if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } - return nil, nil -} - -func processableHeaderOrAvi(f multipart.File) (string, error) { - extension := "" - - head := make([]byte, 261) - _, err := f.Read(head) - if err != nil { - return extension, fmt.Errorf("could not read first magic bytes of file: %s", err) - } - - kind, err := filetype.Match(head) - if err != nil { - return extension, err - } - - if kind == filetype.Unknown || !filetype.IsImage(head) { - return extension, errors.New("filetype is not an image") - } - - if !supportedImageType(kind.MIME.Subtype) { - return extension, fmt.Errorf("%s is not an accepted image type", kind.MIME.Value) - } - - extension = kind.MIME.Subtype - - return extension, nil -} - -func supportedImageType(have string) bool { - for _, accepted := range acceptedImageTypes { - if have == accepted { - return true - } - } - return false + ma := &model.MediaAttachment{ + ID: newMediaID, + StatusID: "", + RemoteURL: "", + Type: "", + FileMeta: model.ImageFileMeta{ + Original: model.ImageOriginal{ + Width: original.width, + Height: original.height, + Size: original.size, + Aspect: original.aspect, + }, + Small: model.Small{ + Width: small.width, + Height: small.height, + Size: small.size, + Aspect: small.aspect, + }, + }, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: "", + Processing: 2, + File: model.File{ + Path: originalPath, + ContentType: contentType, + FileSize: len(original.image), + }, + Thumbnail: model.Thumbnail{ + Path: smallPath, + ContentType: contentType, + FileSize: len(small.image), + RemoteURL: "", + }, + } + + return ma, nil } diff --git a/internal/media/media_test.go b/internal/media/media_test.go new file mode 100644 index 000000000..1874e7ac8 --- /dev/null +++ b/internal/media/media_test.go @@ -0,0 +1,127 @@ +/* + 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" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/storage" +) + +type MediaTestSuite struct { + suite.Suite + config *config.Config + log *logrus.Logger + db db.DB + mediaHandler *mediaHandler + mockStorage storage.Storage +} + +/* + 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.DBConfig = &config.DBConfig{ + Type: "postgres", + Address: "localhost", + Port: 5432, + User: "postgres", + Password: "postgres", + Database: "postgres", + ApplicationName: "gotosocial", + } + suite.config = c + suite.config.MediaConfig.MaxImageSize = 2 << 20 // 2 megabits + + // use an actual database for this, because it's just easier than mocking one out + database, err := db.New(context.Background(), c, log) + if err != nil { + suite.FailNow(err.Error()) + } + suite.db = database + + suite.mockStorage = &storage.MockStorage{} + + // and finally here's the thing we're actually testing! + suite.mediaHandler = &mediaHandler{ + config: suite.config, + db: suite.db, + storage: &storage.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{}{ + &model.Account{}, + &model.MediaAttachment{}, + } + for _, m := range models { + if err := suite.db.CreateTable(m); err != nil { + logrus.Panicf("db connection error: %s", 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{}{ + &model.Account{}, + &model.MediaAttachment{}, + } + for _, m := range models { + if err := suite.db.DropTable(m); err != nil { + logrus.Panicf("error dropping table: %s", err) + } + } +} + +/* + ACTUAL TESTS +*/ + +func TestMediaTestSuite(t *testing.T) { + suite.Run(t, new(MediaTestSuite)) +} diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go index 1f11abd0f..d0a6bb373 100644 --- a/internal/media/mock_MediaHandler.go +++ b/internal/media/mock_MediaHandler.go @@ -3,9 +3,10 @@ package media import ( - multipart "mime/multipart" + io "io" mock "github.com/stretchr/testify/mock" + model "github.com/superseriousbusiness/gotosocial/internal/db/model" ) // MockMediaHandler is an autogenerated mock type for the MediaHandler type @@ -13,22 +14,22 @@ type MockMediaHandler struct { mock.Mock } -// SetHeaderForAccountID provides a mock function with given fields: f, id -func (_m *MockMediaHandler) SetHeaderForAccountID(f multipart.File, id string) (*HeaderInfo, error) { - ret := _m.Called(f, id) +// SetHeaderOrAvatarForAccountID provides a mock function with given fields: f, accountID, headerOrAvi +func (_m *MockMediaHandler) SetHeaderOrAvatarForAccountID(f io.Reader, accountID string, headerOrAvi string) (*model.MediaAttachment, error) { + ret := _m.Called(f, accountID, headerOrAvi) - var r0 *HeaderInfo - if rf, ok := ret.Get(0).(func(multipart.File, string) *HeaderInfo); ok { - r0 = rf(f, id) + var r0 *model.MediaAttachment + if rf, ok := ret.Get(0).(func(io.Reader, string, string) *model.MediaAttachment); ok { + r0 = rf(f, accountID, headerOrAvi) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*HeaderInfo) + r0 = ret.Get(0).(*model.MediaAttachment) } } var r1 error - if rf, ok := ret.Get(1).(func(multipart.File, string) error); ok { - r1 = rf(f, id) + if rf, ok := ret.Get(1).(func(io.Reader, string, string) error); ok { + r1 = rf(f, accountID, headerOrAvi) } else { r1 = ret.Error(1) } diff --git a/internal/media/test/test-jpeg.jpg b/internal/media/test/test-jpeg.jpg new file mode 100644 index 000000000..a9ab154d4 Binary files /dev/null and b/internal/media/test/test-jpeg.jpg differ diff --git a/internal/media/util.go b/internal/media/util.go new file mode 100644 index 000000000..bd1c44dfe --- /dev/null +++ b/internal/media/util.go @@ -0,0 +1,175 @@ +/* + 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 ( + "bytes" + "errors" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + + "github.com/h2non/filetype" + "github.com/nfnt/resize" + exifremove "github.com/scottleedavis/go-exif-remove" +) + +// 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(f io.Reader) (string, error) { + head := make([]byte, 261) + _, err := f.Read(head) + if err != nil { + return "", fmt.Errorf("could not read first magic bytes of file: %s", err) + } + + kind, err := filetype.Match(head) + if err != nil { + return "", err + } + + if kind == filetype.Unknown { + return "", errors.New("filetype unknown") + } + + return kind.MIME.Value, nil +} + +// supportedImageType 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 { + acceptedImageTypes := []string{ + "image/jpeg", + "image/gif", + "image/png", + } + for _, accepted := range acceptedImageTypes { + if mimeType == accepted { + return true + } + } + 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) { + return exifremove.Remove(b) +} + +func deriveImage(b []byte, extension string) (*imageAndMeta, error) { + var i image.Image + var err error + + switch extension { + case "image/jpeg": + i, err = jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case "image/png": + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case "image/gif": + i, err = gif.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("extension %s not recognised", extension) + } + + width := i.Bounds().Size().X + height := i.Bounds().Size().Y + size := width * height + aspect := float64(width) / float64(height) + + out := &bytes.Buffer{} + if err := jpeg.Encode(out, i, nil); err != nil { + return nil, err + } + return &imageAndMeta{ + image: out.Bytes(), + width: width, + height: height, + size: size, + aspect: aspect, + }, nil +} + +// deriveThumbnailFromImage returns a byte slice of an 80-pixel-width thumbnail +// of a given jpeg, png, or gif, or an error if something goes wrong. +// +// Note that the aspect ratio of the image will be retained, +// so it will not necessarily be a square. +func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) { + var i image.Image + var err error + + switch extension { + case "image/jpeg": + i, err = jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case "image/png": + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case "image/gif": + i, err = gif.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("extension %s not recognised", extension) + } + + thumb := resize.Thumbnail(80, 0, i, resize.NearestNeighbor) + width := thumb.Bounds().Size().X + height := thumb.Bounds().Size().Y + size := width * height + aspect := float64(width) / float64(height) + + out := &bytes.Buffer{} + if err := jpeg.Encode(out, thumb, nil); err != nil { + return nil, err + } + return &imageAndMeta{ + image: out.Bytes(), + width: width, + height: height, + size: size, + aspect: aspect, + }, nil +} + +type imageAndMeta struct { + image []byte + width int + height int + size int + aspect float64 +} diff --git a/internal/media/util_test.go b/internal/media/util_test.go new file mode 100644 index 000000000..9f3efe4ae --- /dev/null +++ b/internal/media/util_test.go @@ -0,0 +1,75 @@ +/* + 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 ( + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" +) + +type MediaUtilTestSuite struct { + suite.Suite + log *logrus.Logger +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *MediaUtilTestSuite) SetupSuite() { + // some of our subsequent entities need a log so create this here + log := logrus.New() + log.SetLevel(logrus.TraceLevel) + suite.log = log +} + +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) TestParseContentType() { + f, err := os.Open("./test/test-jpeg.jpg") + if err != nil { + suite.FailNow(err.Error()) + } + ct, err := parseContentType(f) + suite.log.Debug(ct) +} + +func TestMediaUtilTestSuite(t *testing.T) { + suite.Run(t, new(MediaUtilTestSuite)) +} diff --git a/internal/module/account/account.go b/internal/module/account/account.go index 5cd21d608..af417b7ea 100644 --- a/internal/module/account/account.go +++ b/internal/module/account/account.go @@ -177,7 +177,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided header: %s", err)}) return } - headerInfo, err := m.mediaHandler.SetHeaderForAccountID(f, authed.Account.ID) + headerInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(f, authed.Account.ID, "header") if err != nil { l.Debugf("error processing header: %s", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index fa884ed07..18ed0eb27 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -18,7 +18,16 @@ package storage +import "time" + type Storage interface { StoreFileAt(path string, data []byte) error RetrieveFileFrom(path string) ([]byte, error) } + +type FileInfo struct { + Data []byte + StorePath string + CreatedAt time.Time + UpdatedAt time.Time +} \ No newline at end of file