From 6803c1682b26a0d78326dbe45b06f61ac0476d0d Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Mon, 27 Dec 2021 18:03:36 +0100 Subject: [PATCH] start refactor of media package --- internal/media/handler.go | 53 ++----- internal/media/{processimage.go => image.go} | 8 +- internal/media/processicon.go | 6 +- internal/media/{util.go => processing.go} | 158 ++----------------- internal/media/types.go | 149 +++++++++++++++++ internal/media/util_test.go | 4 +- 6 files changed, 183 insertions(+), 195 deletions(-) rename internal/media/{processimage.go => image.go} (94%) rename internal/media/{util.go => processing.go} (50%) create mode 100644 internal/media/types.go diff --git a/internal/media/handler.go b/internal/media/handler.go index e6c7369b6..b64e583b3 100644 --- a/internal/media/handler.go +++ b/internal/media/handler.go @@ -35,45 +35,16 @@ import ( "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 -) +type ProcessedCallback func(*gtsmodel.MediaAttachment) error // 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) + ProcessHeader(ctx context.Context, data []byte, accountID string, cb ProcessedCallback) (*gtsmodel.MediaAttachment, error) + ProcessAvatar(ctx context.Context, data []byte, accountID string, cb ProcessedCallback) (*gtsmodel.MediaAttachment, error) + ProcessAttachment(ctx context.Context, data []byte, accountID string, cb ProcessedCallback) (*gtsmodel.MediaAttachment, error) + ProcessEmoji(ctx context.Context, data []byte, shortcode string) (*gtsmodel.Emoji, error) } type mediaHandler struct { @@ -108,7 +79,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(ctx context.Context, attachment [] if err != nil { return nil, err } - if !SupportedImageType(contentType) { + if !supportedImage(contentType) { return nil, fmt.Errorf("%s is not an accepted image type", contentType) } @@ -152,8 +123,8 @@ func (mh *mediaHandler) ProcessAttachment(ctx context.Context, attachmentBytes [ // return nil, errors.New("video was of size 0") // } // return mh.processVideoAttachment(attachment, accountID, contentType, remoteURL) - case MIMEImage: - if !SupportedImageType(contentType) { + case mimeImage: + if !supportedImage(contentType) { return nil, fmt.Errorf("image type %s not supported", contentType) } if len(attachmentBytes) == 0 { @@ -180,7 +151,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(ctx context.Context, emojiBytes []byte if err != nil { return nil, err } - if !supportedEmojiType(contentType) { + if !supportedEmoji(contentType) { return nil, fmt.Errorf("content type %s not supported for emojis", contentType) } @@ -193,11 +164,11 @@ func (mh *mediaHandler) ProcessLocalEmoji(ctx context.Context, emojiBytes []byte // clean any exif data from png but leave gifs alone switch contentType { - case MIMEPng: + case mimePng: if clean, err = purgeExif(emojiBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case MIMEGif: + case mimeGif: clean = emojiBytes default: return nil, errors.New("media type unrecognized") @@ -266,7 +237,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(ctx context.Context, emojiBytes []byte ImagePath: emojiPath, ImageStaticPath: emojiStaticPath, ImageContentType: contentType, - ImageStaticContentType: MIMEPng, // static version will always be a png + ImageStaticContentType: mimePng, // static version will always be a png ImageFileSize: len(original.image), ImageStaticFileSize: len(static.image), ImageUpdatedAt: time.Now(), diff --git a/internal/media/processimage.go b/internal/media/image.go similarity index 94% rename from internal/media/processimage.go rename to internal/media/image.go index ca92c0660..f1cc03bb6 100644 --- a/internal/media/processimage.go +++ b/internal/media/image.go @@ -29,7 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/uris" ) -func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) processImage(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { var clean []byte var err error var original *imageAndMeta @@ -38,7 +38,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmo contentType := minAttachment.File.ContentType switch contentType { - case MIMEJpeg, MIMEPng: + case mimeJpeg, mimePng: if clean, err = purgeExif(data); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } @@ -46,7 +46,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmo if err != nil { return nil, fmt.Errorf("error parsing image: %s", err) } - case MIMEGif: + case mimeGif: clean = data original, err = deriveGif(clean, contentType) if err != nil { @@ -119,7 +119,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmo }, Thumbnail: gtsmodel.Thumbnail{ Path: smallPath, - ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg + ContentType: mimeJpeg, // all thumbnails/smalls are encoded as jpeg FileSize: len(small.image), UpdatedAt: time.Now(), URL: smallURL, diff --git a/internal/media/processicon.go b/internal/media/processicon.go index 66cf1f999..faeae0ee6 100644 --- a/internal/media/processicon.go +++ b/internal/media/processicon.go @@ -47,17 +47,17 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string var original *imageAndMeta switch contentType { - case MIMEJpeg: + 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: + 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: + case mimeGif: clean = imageBytes original, err = deriveGif(clean, contentType) default: diff --git a/internal/media/util.go b/internal/media/processing.go similarity index 50% rename from internal/media/util.go rename to internal/media/processing.go index 348136c92..ccd9ebfdb 100644 --- a/internal/media/util.go +++ b/internal/media/processing.go @@ -1,21 +1,3 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - package media import ( @@ -28,112 +10,26 @@ import ( "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) - } - - 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{ - MIMEJpeg, - MIMEGif, - MIMEPng, - } - for _, accepted := range acceptedImageTypes { - if mimeType == accepted { - return true - } - } - 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 { - acceptedEmojiTypes := []string{ - MIMEGif, - MIMEPng, - } - for _, accepted := range acceptedEmojiTypes { - 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) { - if len(b) == 0 { +func purgeExif(data []byte) ([]byte, error) { + if len(data) == 0 { return nil, errors.New("passed image was not valid") } - clean, err := exifremove.Remove(b) + clean, err := exifremove.Remove(data) if err != nil { return nil, fmt.Errorf("could not purge exif from image: %s", err) } + if len(clean) == 0 { return nil, errors.New("purged image was not valid") } + return clean, nil } @@ -141,7 +37,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) { var g *gif.GIF var err error switch extension { - case MIMEGif: + case mimeGif: g, err = gif.DecodeAll(bytes.NewReader(b)) if err != nil { return nil, err @@ -170,12 +66,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case MIMEJpeg: + case mimeImageJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case MIMEPng: + case mimeImagePng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -208,17 +104,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet var err error switch contentType { - case MIMEJpeg: + case mimeImageJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case MIMEPng: + case mimeImagePng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case MIMEGif: + case mimeImageGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -261,12 +157,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case MIMEPng: + case mimeImagePng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case MIMEGif: + case mimeImageGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -292,31 +188,3 @@ type imageAndMeta struct { 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 { - case string(TypeAttachment): - return TypeAttachment, nil - case string(TypeHeader): - return TypeHeader, nil - case string(TypeAvatar): - return TypeAvatar, nil - case string(TypeEmoji): - return TypeEmoji, nil - } - return "", fmt.Errorf("%s not a recognized MediaType", s) -} - -// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized -func ParseMediaSize(s string) (Size, error) { - switch s { - case string(SizeSmall): - return SizeSmall, nil - case string(SizeOriginal): - return SizeOriginal, nil - case string(SizeStatic): - return SizeStatic, nil - } - return "", fmt.Errorf("%s not a recognized MediaSize", s) -} diff --git a/internal/media/types.go b/internal/media/types.go new file mode 100644 index 000000000..f1608f880 --- /dev/null +++ b/internal/media/types.go @@ -0,0 +1,149 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package media + +import ( + "bytes" + "errors" + "fmt" + + "github.com/h2non/filetype" +) + +// mime consts +const ( + mimeImage = "image" + + mimeJpeg = "jpeg" + mimeImageJpeg = mimeImage + "/" + mimeJpeg + + mimeGif = "gif" + mimeImageGif = mimeImage + "/" + mimeGif + + mimePng = "png" + mimeImagePng = mimeImage + "/" + mimePng +) + + +// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb) +// const EmojiMaxBytes = 51200 + +// 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 = 262 + +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 +) + +// parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). +// Returns an error if the content type is not something we can process. +func parseContentType(content []byte) (string, error) { + + // read in the first bytes of the file + fileHeader := make([]byte, maxFileHeaderBytes) + if _, err := bytes.NewReader(content).Read(fileHeader); err != nil { + return "", fmt.Errorf("could not read first magic bytes of file: %s", err) + } + + kind, err := filetype.Match(fileHeader) + if err != nil { + return "", err + } + + if kind == filetype.Unknown { + return "", errors.New("filetype unknown") + } + + return kind.MIME.Value, nil +} + +// supportedImage checks mime type of an image against a slice of accepted types, +// and returns True if the mime type is accepted. +func supportedImage(mimeType string) bool { + acceptedImageTypes := []string{ + mimeImageJpeg, + mimeImageGif, + mimeImagePng, + } + for _, accepted := range acceptedImageTypes { + if mimeType == accepted { + return true + } + } + return false +} + +// supportedEmoji checks that the content type is image/png -- the only type supported for emoji. +func supportedEmoji(mimeType string) bool { + acceptedEmojiTypes := []string{ + mimeImageGif, + mimeImagePng, + } + for _, accepted := range acceptedEmojiTypes { + if mimeType == accepted { + return true + } + } + return false +} + +// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized +func ParseMediaType(s string) (Type, error) { + switch s { + case string(TypeAttachment): + return TypeAttachment, nil + case string(TypeHeader): + return TypeHeader, nil + case string(TypeAvatar): + return TypeAvatar, nil + case string(TypeEmoji): + return TypeEmoji, nil + } + return "", fmt.Errorf("%s not a recognized MediaType", s) +} + +// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized +func ParseMediaSize(s string) (Size, error) { + switch s { + case string(SizeSmall): + return SizeSmall, nil + case string(SizeOriginal): + return SizeOriginal, nil + case string(SizeStatic): + return SizeStatic, nil + } + return "", fmt.Errorf("%s not a recognized MediaSize", s) +} diff --git a/internal/media/util_test.go b/internal/media/util_test.go index cb299d50e..817b597cb 100644 --- a/internal/media/util_test.go +++ b/internal/media/util_test.go @@ -138,10 +138,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() { } func (suite *MediaUtilTestSuite) TestSupportedImageTypes() { - ok := SupportedImageType("image/jpeg") + ok := supportedImage("image/jpeg") suite.True(ok) - ok = SupportedImageType("image/bmp") + ok = supportedImage("image/bmp") suite.False(ok) }