gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 6803c1682b26a0d78326dbe45b06f61ac0476d0d
parent 14c472c1ab2eff3d47200374d3ea1f74e6eb8cda
Author: tsmethurst <tobi.smethurst@protonmail.com>
Date:   Mon, 27 Dec 2021 18:03:36 +0100

start refactor of media package

Diffstat:
Minternal/media/handler.go | 53++++++++++++-----------------------------------------
Ainternal/media/image.go | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/media/processicon.go | 6+++---
Dinternal/media/processimage.go | 133-------------------------------------------------------------------------------
Ainternal/media/processing.go | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/media/types.go | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/media/util.go | 322-------------------------------------------------------------------------------
Minternal/media/util_test.go | 4++--
8 files changed, 489 insertions(+), 501 deletions(-)

diff --git 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/image.go b/internal/media/image.go @@ -0,0 +1,133 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +func (mh *mediaHandler) processImage(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { + var clean []byte + var err error + var original *imageAndMeta + var small *imageAndMeta + + contentType := minAttachment.File.ContentType + + switch contentType { + case mimeJpeg, mimePng: + if clean, err = purgeExif(data); err != nil { + return nil, fmt.Errorf("error cleaning exif data: %s", err) + } + original, err = deriveImage(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error parsing image: %s", err) + } + case mimeGif: + clean = data + original, err = deriveGif(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error parsing gif: %s", err) + } + default: + return nil, errors.New("media type unrecognized") + } + + small, err = deriveThumbnail(clean, contentType, 512, 512) + if err != nil { + return nil, fmt.Errorf("error deriving thumbnail: %s", err) + } + + // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it + extension := strings.Split(contentType, "/")[1] + newMediaID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + originalURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeOriginal), newMediaID, extension) + smallURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeSmall), newMediaID, "jpeg") // all thumbnails/smalls are encoded as jpeg + + // we store the original... + originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", minAttachment.AccountID, TypeAttachment, SizeOriginal, newMediaID, extension) + if err := mh.storage.Put(originalPath, original.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + // and a thumbnail... + smallPath := fmt.Sprintf("%s/%s/%s/%s.jpeg", minAttachment.AccountID, TypeAttachment, SizeSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg + if err := mh.storage.Put(smallPath, small.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + minAttachment.FileMeta.Original = gtsmodel.Original{ + Width: original.width, + Height: original.height, + Size: original.size, + Aspect: original.aspect, + } + + minAttachment.FileMeta.Small = gtsmodel.Small{ + Width: small.width, + Height: small.height, + Size: small.size, + Aspect: small.aspect, + } + + attachment := &gtsmodel.MediaAttachment{ + ID: newMediaID, + StatusID: minAttachment.StatusID, + URL: originalURL, + RemoteURL: minAttachment.RemoteURL, + CreatedAt: minAttachment.CreatedAt, + UpdatedAt: minAttachment.UpdatedAt, + Type: gtsmodel.FileTypeImage, + FileMeta: minAttachment.FileMeta, + AccountID: minAttachment.AccountID, + Description: minAttachment.Description, + ScheduledStatusID: minAttachment.ScheduledStatusID, + Blurhash: small.blurhash, + Processing: 2, + File: gtsmodel.File{ + Path: originalPath, + ContentType: contentType, + FileSize: len(original.image), + UpdatedAt: time.Now(), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: smallPath, + ContentType: mimeJpeg, // all thumbnails/smalls are encoded as jpeg + FileSize: len(small.image), + UpdatedAt: time.Now(), + URL: smallURL, + RemoteURL: minAttachment.Thumbnail.RemoteURL, + }, + Avatar: minAttachment.Avatar, + Header: minAttachment.Header, + } + + return attachment, nil +} diff --git a/internal/media/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/processimage.go b/internal/media/processimage.go @@ -1,133 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/uris" -) - -func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) { - var clean []byte - var err error - var original *imageAndMeta - var small *imageAndMeta - - contentType := minAttachment.File.ContentType - - switch contentType { - case MIMEJpeg, MIMEPng: - if clean, err = purgeExif(data); err != nil { - return nil, fmt.Errorf("error cleaning exif data: %s", err) - } - original, err = deriveImage(clean, contentType) - if err != nil { - return nil, fmt.Errorf("error parsing image: %s", err) - } - case MIMEGif: - clean = data - original, err = deriveGif(clean, contentType) - if err != nil { - return nil, fmt.Errorf("error parsing gif: %s", err) - } - default: - return nil, errors.New("media type unrecognized") - } - - small, err = deriveThumbnail(clean, contentType, 512, 512) - if err != nil { - return nil, fmt.Errorf("error deriving thumbnail: %s", err) - } - - // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it - extension := strings.Split(contentType, "/")[1] - newMediaID, err := id.NewRandomULID() - if err != nil { - return nil, err - } - - originalURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeOriginal), newMediaID, extension) - smallURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeSmall), newMediaID, "jpeg") // all thumbnails/smalls are encoded as jpeg - - // we store the original... - originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", minAttachment.AccountID, TypeAttachment, SizeOriginal, newMediaID, extension) - if err := mh.storage.Put(originalPath, original.image); err != nil { - return nil, fmt.Errorf("storage error: %s", err) - } - - // and a thumbnail... - smallPath := fmt.Sprintf("%s/%s/%s/%s.jpeg", minAttachment.AccountID, TypeAttachment, SizeSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg - if err := mh.storage.Put(smallPath, small.image); err != nil { - return nil, fmt.Errorf("storage error: %s", err) - } - - minAttachment.FileMeta.Original = gtsmodel.Original{ - Width: original.width, - Height: original.height, - Size: original.size, - Aspect: original.aspect, - } - - minAttachment.FileMeta.Small = gtsmodel.Small{ - Width: small.width, - Height: small.height, - Size: small.size, - Aspect: small.aspect, - } - - attachment := &gtsmodel.MediaAttachment{ - ID: newMediaID, - StatusID: minAttachment.StatusID, - URL: originalURL, - RemoteURL: minAttachment.RemoteURL, - CreatedAt: minAttachment.CreatedAt, - UpdatedAt: minAttachment.UpdatedAt, - Type: gtsmodel.FileTypeImage, - FileMeta: minAttachment.FileMeta, - AccountID: minAttachment.AccountID, - Description: minAttachment.Description, - ScheduledStatusID: minAttachment.ScheduledStatusID, - Blurhash: small.blurhash, - Processing: 2, - File: gtsmodel.File{ - Path: originalPath, - ContentType: contentType, - FileSize: len(original.image), - UpdatedAt: time.Now(), - }, - Thumbnail: gtsmodel.Thumbnail{ - Path: smallPath, - ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg - FileSize: len(small.image), - UpdatedAt: time.Now(), - URL: smallURL, - RemoteURL: minAttachment.Thumbnail.RemoteURL, - }, - Avatar: minAttachment.Avatar, - Header: minAttachment.Header, - } - - return attachment, nil -} diff --git a/internal/media/processing.go b/internal/media/processing.go @@ -0,0 +1,190 @@ +package media + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + + "github.com/buckket/go-blurhash" + "github.com/nfnt/resize" + "github.com/superseriousbusiness/exifremove/pkg/exifremove" +) + +// 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(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("passed image was not valid") + } + + 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 +} + +func deriveGif(b []byte, extension string) (*imageAndMeta, error) { + var g *gif.GIF + var err error + switch extension { + case mimeGif: + g, err = gif.DecodeAll(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("extension %s not recognised", extension) + } + + // use the first frame to get the static characteristics + width := g.Config.Width + height := g.Config.Height + size := width * height + aspect := float64(width) / float64(height) + + return &imageAndMeta{ + image: b, + width: width, + height: height, + size: size, + aspect: aspect, + }, nil +} + +func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { + var i image.Image + var err error + + switch contentType { + case mimeImageJpeg: + i, err = jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case mimeImagePng: + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("content type %s not recognised", contentType) + } + + width := i.Bounds().Size().X + height := i.Bounds().Size().Y + size := width * height + aspect := float64(width) / float64(height) + + return &imageAndMeta{ + image: b, + width: width, + height: height, + size: size, + aspect: aspect, + }, nil +} + +// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y, +// of a given jpeg, png, or gif, or an error if something goes wrong. +// +// Note that the aspect ratio of the image will be retained, +// so it will not necessarily be a square, even if x and y are set as the same value. +func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) { + var i image.Image + var err error + + switch contentType { + case mimeImageJpeg: + i, err = jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case mimeImagePng: + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case mimeImageGif: + i, err = gif.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("content type %s not recognised", contentType) + } + + thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor) + width := thumb.Bounds().Size().X + height := thumb.Bounds().Size().Y + size := width * height + aspect := float64(width) / float64(height) + + tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor) + bh, err := blurhash.Encode(4, 3, tiny) + if err != nil { + return nil, err + } + + out := &bytes.Buffer{} + if err := jpeg.Encode(out, thumb, &jpeg.Options{ + Quality: 75, + }); err != nil { + return nil, err + } + return &imageAndMeta{ + image: out.Bytes(), + width: width, + height: height, + size: size, + aspect: aspect, + blurhash: bh, + }, nil +} + +// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. +func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { + var i image.Image + var err error + + switch contentType { + case mimeImagePng: + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case mimeImageGif: + i, err = gif.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) + } + + out := &bytes.Buffer{} + if err := png.Encode(out, i); err != nil { + return nil, err + } + return &imageAndMeta{ + image: out.Bytes(), + }, nil +} + +type imageAndMeta struct { + image []byte + width int + height int + size int + aspect float64 + blurhash string +} diff --git a/internal/media/types.go 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 <http://www.gnu.org/licenses/>. +*/ + +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.go b/internal/media/util.go @@ -1,322 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( - "bytes" - "errors" - "fmt" - "image" - "image/gif" - "image/jpeg" - "image/png" - - "github.com/buckket/go-blurhash" - "github.com/h2non/filetype" - "github.com/nfnt/resize" - "github.com/superseriousbusiness/exifremove/pkg/exifremove" -) - -const ( - // MIMEImage is the mime type for image - MIMEImage = "image" - // MIMEJpeg is the jpeg image mime type - MIMEJpeg = "image/jpeg" - // MIMEGif is the gif image mime type - MIMEGif = "image/gif" - // MIMEPng is the png image mime type - MIMEPng = "image/png" - - // MIMEVideo is the mime type for video - MIMEVideo = "video" - // MIMEMp4 is the mp4 video mime type - MIMEMp4 = "video/mp4" - // MIMEMpeg is the mpeg video mime type - MIMEMpeg = "video/mpeg" - // MIMEWebm is the webm video mime type - MIMEWebm = "video/webm" -) - -// parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). -// Returns an error if the content type is not something we can process. -func parseContentType(content []byte) (string, error) { - head := make([]byte, 261) - _, err := bytes.NewReader(content).Read(head) - if err != nil { - return "", fmt.Errorf("could not read first magic bytes of file: %s", err) - } - - 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 { - return nil, errors.New("passed image was not valid") - } - - clean, err := exifremove.Remove(b) - if err != nil { - return nil, fmt.Errorf("could not purge exif from image: %s", err) - } - if len(clean) == 0 { - return nil, errors.New("purged image was not valid") - } - return clean, nil -} - -func deriveGif(b []byte, extension string) (*imageAndMeta, error) { - var g *gif.GIF - var err error - switch extension { - case MIMEGif: - g, err = gif.DecodeAll(bytes.NewReader(b)) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("extension %s not recognised", extension) - } - - // use the first frame to get the static characteristics - width := g.Config.Width - height := g.Config.Height - size := width * height - aspect := float64(width) / float64(height) - - return &imageAndMeta{ - image: b, - width: width, - height: height, - size: size, - aspect: aspect, - }, nil -} - -func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { - var i image.Image - var err error - - switch contentType { - case MIMEJpeg: - i, err = jpeg.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - case MIMEPng: - i, err = png.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("content type %s not recognised", contentType) - } - - width := i.Bounds().Size().X - height := i.Bounds().Size().Y - size := width * height - aspect := float64(width) / float64(height) - - return &imageAndMeta{ - image: b, - width: width, - height: height, - size: size, - aspect: aspect, - }, nil -} - -// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y, -// of a given jpeg, png, or gif, or an error if something goes wrong. -// -// Note that the aspect ratio of the image will be retained, -// so it will not necessarily be a square, even if x and y are set as the same value. -func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) { - var i image.Image - var err error - - switch contentType { - case MIMEJpeg: - i, err = jpeg.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - case MIMEPng: - i, err = png.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - case MIMEGif: - i, err = gif.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("content type %s not recognised", contentType) - } - - thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor) - width := thumb.Bounds().Size().X - height := thumb.Bounds().Size().Y - size := width * height - aspect := float64(width) / float64(height) - - tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor) - bh, err := blurhash.Encode(4, 3, tiny) - if err != nil { - return nil, err - } - - out := &bytes.Buffer{} - if err := jpeg.Encode(out, thumb, &jpeg.Options{ - Quality: 75, - }); err != nil { - return nil, err - } - return &imageAndMeta{ - image: out.Bytes(), - width: width, - height: height, - size: size, - aspect: aspect, - blurhash: bh, - }, nil -} - -// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. -func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { - var i image.Image - var err error - - switch contentType { - case MIMEPng: - i, err = png.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - case MIMEGif: - i, err = gif.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) - } - - out := &bytes.Buffer{} - if err := png.Encode(out, i); err != nil { - return nil, err - } - return &imageAndMeta{ - image: out.Bytes(), - }, nil -} - -type imageAndMeta struct { - image []byte - width int - height int - size int - aspect float64 - blurhash string -} - -// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized -func ParseMediaType(s string) (Type, error) { - switch s { - 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 @@ -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) }