summaryrefslogtreecommitdiff
path: root/internal/media
diff options
context:
space:
mode:
Diffstat (limited to 'internal/media')
-rw-r--r--internal/media/handler.go318
-rw-r--r--internal/media/image.go198
-rw-r--r--internal/media/manager.go176
-rw-r--r--internal/media/manager_test.go363
-rw-r--r--internal/media/media_test.go54
-rw-r--r--internal/media/processicon.go143
-rw-r--r--internal/media/processimage.go133
-rw-r--r--internal/media/processingemoji.go291
-rw-r--r--internal/media/processingmedia.go410
-rw-r--r--internal/media/processvideo.go23
-rw-r--r--internal/media/test/test-corrupted.jpg1
-rw-r--r--internal/media/test/test-jpeg-blurhash.jpgbin8802 -> 0 bytes
-rw-r--r--internal/media/test/test-with-exif.jpgbin1227452 -> 0 bytes
-rw-r--r--internal/media/test/test-without-exif.jpgbin1227452 -> 0 bytes
-rw-r--r--internal/media/types.go121
-rw-r--r--internal/media/util.go247
-rw-r--r--internal/media/util_test.go150
17 files changed, 1628 insertions, 1000 deletions
diff --git a/internal/media/handler.go b/internal/media/handler.go
deleted file mode 100644
index e6c7369b6..000000000
--- a/internal/media/handler.go
+++ /dev/null
@@ -1,318 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "context"
- "errors"
- "fmt"
- "net/url"
- "strings"
- "time"
-
- "codeberg.org/gruf/go-store/kv"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/id"
- "github.com/superseriousbusiness/gotosocial/internal/transport"
- "github.com/superseriousbusiness/gotosocial/internal/uris"
-)
-
-// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
-const EmojiMaxBytes = 51200
-
-type Size string
-
-const (
- SizeSmall Size = "small" // SizeSmall is the key for small/thumbnail versions of media
- SizeOriginal Size = "original" // SizeOriginal is the key for original/fullsize versions of media and emoji
- SizeStatic Size = "static" // SizeStatic is the key for static (non-animated) versions of emoji
-)
-
-type Type string
-
-const (
- TypeAttachment Type = "attachment" // TypeAttachment is the key for media attachments
- TypeHeader Type = "header" // TypeHeader is the key for profile header requests
- TypeAvatar Type = "avatar" // TypeAvatar is the key for profile avatar requests
- TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests
-)
-
-// Handler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs.
-type Handler interface {
- // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
- // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
- // and then returns information to the caller about the new header.
- ProcessHeaderOrAvatar(ctx context.Context, attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error)
-
- // ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
- // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
- // and then returns information to the caller about the attachment. It's the caller's responsibility to put the returned struct
- // in the database.
- ProcessAttachment(ctx context.Context, attachmentBytes []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error)
-
- // ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new
- // *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct
- // in the database.
- ProcessLocalEmoji(ctx context.Context, emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error)
-
- ProcessRemoteHeaderOrAvatar(ctx context.Context, t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error)
-}
-
-type mediaHandler struct {
- db db.DB
- storage *kv.KVStore
-}
-
-// New returns a new handler with the given db and storage
-func New(database db.DB, storage *kv.KVStore) Handler {
- return &mediaHandler{
- db: database,
- storage: storage,
- }
-}
-
-/*
- INTERFACE FUNCTIONS
-*/
-
-// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
-// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
-// and then returns information to the caller about the new header.
-func (mh *mediaHandler) ProcessHeaderOrAvatar(ctx context.Context, attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error) {
- l := logrus.WithField("func", "SetHeaderForAccountID")
-
- if mediaType != TypeHeader && mediaType != TypeAvatar {
- return nil, errors.New("header or avatar not selected")
- }
-
- // make sure we have a type we can handle
- contentType, err := parseContentType(attachment)
- if err != nil {
- return nil, err
- }
- if !SupportedImageType(contentType) {
- return nil, fmt.Errorf("%s is not an accepted image type", contentType)
- }
-
- if len(attachment) == 0 {
- return nil, fmt.Errorf("passed reader was of size 0")
- }
- l.Tracef("read %d bytes of file", len(attachment))
-
- // process it
- ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID, remoteURL)
- if err != nil {
- return nil, fmt.Errorf("error processing %s: %s", mediaType, err)
- }
-
- // set it in the database
- if err := mh.db.SetAccountHeaderOrAvatar(ctx, ma, accountID); err != nil {
- return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err)
- }
-
- return ma, nil
-}
-
-// ProcessAttachment takes a new attachment and the owning account, checks it out, removes exif data from it,
-// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
-// and then returns information to the caller about the attachment.
-func (mh *mediaHandler) ProcessAttachment(ctx context.Context, attachmentBytes []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) {
- contentType, err := parseContentType(attachmentBytes)
- if err != nil {
- return nil, err
- }
-
- minAttachment.File.ContentType = contentType
-
- mainType := strings.Split(contentType, "/")[0]
- switch mainType {
- // case MIMEVideo:
- // if !SupportedVideoType(contentType) {
- // return nil, fmt.Errorf("video type %s not supported", contentType)
- // }
- // if len(attachment) == 0 {
- // return nil, errors.New("video was of size 0")
- // }
- // return mh.processVideoAttachment(attachment, accountID, contentType, remoteURL)
- case MIMEImage:
- if !SupportedImageType(contentType) {
- return nil, fmt.Errorf("image type %s not supported", contentType)
- }
- if len(attachmentBytes) == 0 {
- return nil, errors.New("image was of size 0")
- }
- return mh.processImageAttachment(attachmentBytes, minAttachment)
- default:
- break
- }
- return nil, fmt.Errorf("content type %s not (yet) supported", contentType)
-}
-
-// ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new
-// *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct
-// in the database.
-func (mh *mediaHandler) ProcessLocalEmoji(ctx context.Context, emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error) {
- var clean []byte
- var err error
- var original *imageAndMeta
- var static *imageAndMeta
-
- // check content type of the submitted emoji and make sure it's supported by us
- contentType, err := parseContentType(emojiBytes)
- if err != nil {
- return nil, err
- }
- if !supportedEmojiType(contentType) {
- return nil, fmt.Errorf("content type %s not supported for emojis", contentType)
- }
-
- if len(emojiBytes) == 0 {
- return nil, errors.New("emoji was of size 0")
- }
- if len(emojiBytes) > EmojiMaxBytes {
- return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes)
- }
-
- // clean any exif data from png but leave gifs alone
- switch contentType {
- case MIMEPng:
- if clean, err = purgeExif(emojiBytes); err != nil {
- return nil, fmt.Errorf("error cleaning exif data: %s", err)
- }
- case MIMEGif:
- clean = emojiBytes
- default:
- return nil, errors.New("media type unrecognized")
- }
-
- // unlike with other attachments we don't need to derive anything here because we don't care about the width/height etc
- original = &imageAndMeta{
- image: clean,
- }
-
- static, err = deriveStaticEmoji(clean, contentType)
- if err != nil {
- return nil, fmt.Errorf("error deriving static emoji: %s", err)
- }
-
- // since emoji aren't 'owned' by an account, but we still want to use the same pattern for serving them through the filserver,
- // (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created
- // with the same username as the instance hostname, which doesn't belong to any particular user.
- instanceAccount, err := mh.db.GetInstanceAccount(ctx, "")
- if err != nil {
- return nil, fmt.Errorf("error fetching instance account: %s", err)
- }
-
- // the file extension (either png or gif)
- extension := strings.Split(contentType, "/")[1]
-
- // generate a ulid for the new emoji
- newEmojiID, err := id.NewRandomULID()
- if err != nil {
- return nil, err
- }
-
- // activitypub uri for the emoji -- unrelated to actually serving the image
- // will be something like https://example.org/emoji/01FPSVBK3H8N7V8XK6KGSQ86EC
- emojiURI := uris.GenerateURIForEmoji(newEmojiID)
-
- // serve url and storage path for the original emoji -- can be png or gif
- emojiURL := uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeOriginal), newEmojiID, extension)
- emojiPath := fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeOriginal, newEmojiID, extension)
-
- // serve url and storage path for the static version -- will always be png
- emojiStaticURL := uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), newEmojiID, "png")
- emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s.png", instanceAccount.ID, TypeEmoji, SizeStatic, newEmojiID)
-
- // Store the original emoji
- if err := mh.storage.Put(emojiPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // Store the static emoji
- if err := mh.storage.Put(emojiStaticPath, static.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and finally return the new emoji data to the caller -- it's up to them what to do with it
- e := &gtsmodel.Emoji{
- ID: newEmojiID,
- Shortcode: shortcode,
- Domain: "", // empty because this is a local emoji
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- ImageRemoteURL: "", // empty because this is a local emoji
- ImageStaticRemoteURL: "", // empty because this is a local emoji
- ImageURL: emojiURL,
- ImageStaticURL: emojiStaticURL,
- ImagePath: emojiPath,
- ImageStaticPath: emojiStaticPath,
- ImageContentType: contentType,
- ImageStaticContentType: MIMEPng, // static version will always be a png
- ImageFileSize: len(original.image),
- ImageStaticFileSize: len(static.image),
- ImageUpdatedAt: time.Now(),
- Disabled: false,
- URI: emojiURI,
- VisibleInPicker: true,
- CategoryID: "", // empty because this is a new emoji -- no category yet
- }
- return e, nil
-}
-
-func (mh *mediaHandler) ProcessRemoteHeaderOrAvatar(ctx context.Context, t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) {
- if !currentAttachment.Header && !currentAttachment.Avatar {
- return nil, errors.New("provided attachment was set to neither header nor avatar")
- }
-
- if currentAttachment.Header && currentAttachment.Avatar {
- return nil, errors.New("provided attachment was set to both header and avatar")
- }
-
- var headerOrAvi Type
- if currentAttachment.Header {
- headerOrAvi = TypeHeader
- } else if currentAttachment.Avatar {
- headerOrAvi = TypeAvatar
- }
-
- if currentAttachment.RemoteURL == "" {
- return nil, errors.New("no remote URL on media attachment to dereference")
- }
- remoteIRI, err := url.Parse(currentAttachment.RemoteURL)
- if err != nil {
- return nil, fmt.Errorf("error parsing attachment url %s: %s", currentAttachment.RemoteURL, err)
- }
-
- // for content type, we assume we don't know what to expect...
- expectedContentType := "*/*"
- if currentAttachment.File.ContentType != "" {
- // ... and then narrow it down if we do
- expectedContentType = currentAttachment.File.ContentType
- }
-
- attachmentBytes, err := t.DereferenceMedia(ctx, remoteIRI, expectedContentType)
- if err != nil {
- return nil, fmt.Errorf("dereferencing remote media with url %s: %s", remoteIRI.String(), err)
- }
-
- return mh.ProcessHeaderOrAvatar(ctx, attachmentBytes, accountID, headerOrAvi, currentAttachment.RemoteURL)
-}
diff --git a/internal/media/image.go b/internal/media/image.go
new file mode 100644
index 000000000..e5390cee5
--- /dev/null
+++ b/internal/media/image.go
@@ -0,0 +1,198 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "image"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+
+ "github.com/buckket/go-blurhash"
+ "github.com/nfnt/resize"
+)
+
+const (
+ thumbnailMaxWidth = 512
+ thumbnailMaxHeight = 512
+)
+
+type imageMeta struct {
+ width int
+ height int
+ size int
+ aspect float64
+ blurhash string // defined only for calls to deriveThumbnail if createBlurhash is true
+ small []byte // defined only for calls to deriveStaticEmoji or deriveThumbnail
+}
+
+func decodeGif(r io.Reader) (*imageMeta, error) {
+ gif, err := gif.DecodeAll(r)
+ if err != nil {
+ return nil, err
+ }
+
+ // use the first frame to get the static characteristics
+ width := gif.Config.Width
+ height := gif.Config.Height
+ size := width * height
+ aspect := float64(width) / float64(height)
+
+ return &imageMeta{
+ width: width,
+ height: height,
+ size: size,
+ aspect: aspect,
+ }, nil
+}
+
+func decodeImage(r io.Reader, contentType string) (*imageMeta, error) {
+ var i image.Image
+ var err error
+
+ switch contentType {
+ case mimeImageJpeg:
+ i, err = jpeg.Decode(r)
+ case mimeImagePng:
+ i, err = png.Decode(r)
+ default:
+ err = fmt.Errorf("content type %s not recognised", contentType)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if i == nil {
+ return nil, errors.New("processed image was nil")
+ }
+
+ width := i.Bounds().Size().X
+ height := i.Bounds().Size().Y
+ size := width * height
+ aspect := float64(width) / float64(height)
+
+ return &imageMeta{
+ width: width,
+ height: height,
+ size: size,
+ aspect: aspect,
+ }, nil
+}
+
+// deriveThumbnail returns a byte slice and metadata for a thumbnail
+// of a given jpeg, png, or gif, or an error if something goes wrong.
+//
+// If createBlurhash is true, then a blurhash will also be generated from a tiny
+// version of the image. This costs precious CPU cycles, so only use it if you
+// really need a blurhash and don't have one already.
+//
+// If createBlurhash is false, then the blurhash field on the returned ImageAndMeta
+// will be an empty string.
+func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*imageMeta, error) {
+ var i image.Image
+ var err error
+
+ switch contentType {
+ case mimeImageJpeg:
+ i, err = jpeg.Decode(r)
+ case mimeImagePng:
+ i, err = png.Decode(r)
+ case mimeImageGif:
+ i, err = gif.Decode(r)
+ default:
+ err = fmt.Errorf("content type %s can't be thumbnailed", contentType)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if i == nil {
+ return nil, errors.New("processed image was nil")
+ }
+
+ thumb := resize.Thumbnail(thumbnailMaxWidth, thumbnailMaxHeight, i, resize.NearestNeighbor)
+ width := thumb.Bounds().Size().X
+ height := thumb.Bounds().Size().Y
+ size := width * height
+ aspect := float64(width) / float64(height)
+
+ im := &imageMeta{
+ width: width,
+ height: height,
+ size: size,
+ aspect: aspect,
+ }
+
+ if createBlurhash {
+ // for generating blurhashes, it's more cost effective to lose detail rather than
+ // pass a big image into the blurhash algorithm, so make a teeny tiny version
+ tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor)
+ bh, err := blurhash.Encode(4, 3, tiny)
+ if err != nil {
+ return nil, err
+ }
+ im.blurhash = bh
+ }
+
+ out := &bytes.Buffer{}
+ if err := jpeg.Encode(out, thumb, &jpeg.Options{
+ // Quality isn't extremely important for thumbnails, so 75 is "good enough"
+ Quality: 75,
+ }); err != nil {
+ return nil, err
+ }
+ im.small = out.Bytes()
+
+ return im, nil
+}
+
+// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png.
+func deriveStaticEmoji(r io.Reader, contentType string) (*imageMeta, error) {
+ var i image.Image
+ var err error
+
+ switch contentType {
+ case mimeImagePng:
+ i, err = png.Decode(r)
+ if err != nil {
+ return nil, err
+ }
+ case mimeImageGif:
+ i, err = gif.Decode(r)
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("content type %s not allowed for emoji", contentType)
+ }
+
+ out := &bytes.Buffer{}
+ if err := png.Encode(out, i); err != nil {
+ return nil, err
+ }
+ return &imageMeta{
+ small: out.Bytes(),
+ }, nil
+}
diff --git a/internal/media/manager.go b/internal/media/manager.go
new file mode 100644
index 000000000..7f626271a
--- /dev/null
+++ b/internal/media/manager.go
@@ -0,0 +1,176 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "context"
+ "errors"
+ "runtime"
+
+ "codeberg.org/gruf/go-runners"
+ "codeberg.org/gruf/go-store/kv"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+)
+
+// Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs.
+type Manager interface {
+ // ProcessMedia begins the process of decoding and storing the given data as an attachment.
+ // It will return a pointer to a Media struct upon which further actions can be performed, such as getting
+ // the finished media, thumbnail, attachment, etc.
+ //
+ // data should be a function that the media manager can call to return raw bytes of a piece of media.
+ //
+ // accountID should be the account that the media belongs to.
+ //
+ // ai is optional and can be nil. Any additional information about the attachment provided will be put in the database.
+ ProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error)
+ ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error)
+ // NumWorkers returns the total number of workers available to this manager.
+ NumWorkers() int
+ // QueueSize returns the total capacity of the queue.
+ QueueSize() int
+ // JobsQueued returns the number of jobs currently in the task queue.
+ JobsQueued() int
+ // ActiveWorkers returns the number of workers currently performing jobs.
+ ActiveWorkers() int
+ // Stop stops the underlying worker pool of the manager. It should be called
+ // when closing GoToSocial in order to cleanly finish any in-progress jobs.
+ // It will block until workers are finished processing.
+ Stop() error
+}
+
+type manager struct {
+ db db.DB
+ storage *kv.KVStore
+ pool runners.WorkerPool
+ numWorkers int
+ queueSize int
+}
+
+// NewManager returns a media manager with the given db and underlying storage.
+//
+// A worker pool will also be initialized for the manager, to ensure that only
+// a limited number of media will be processed in parallel.
+//
+// The number of workers will be the number of CPUs available to the Go runtime,
+// divided by 2 (rounding down, but always at least 1).
+//
+// The length of the queue will be the number of workers multiplied by 10.
+//
+// So for an 8 core machine, the media manager will get 4 workers, and a queue of length 40.
+// For a 4 core machine, this will be 2 workers, and a queue length of 20.
+// For a single or 2-core machine, the media manager will get 1 worker, and a queue of length 10.
+func NewManager(database db.DB, storage *kv.KVStore) (Manager, error) {
+ numWorkers := runtime.NumCPU() / 2
+ // make sure we always have at least 1 worker even on single-core machines
+ if numWorkers == 0 {
+ numWorkers = 1
+ }
+ queueSize := numWorkers * 10
+
+ m := &manager{
+ db: database,
+ storage: storage,
+ pool: runners.NewWorkerPool(numWorkers, queueSize),
+ numWorkers: numWorkers,
+ queueSize: queueSize,
+ }
+
+ if start := m.pool.Start(); !start {
+ return nil, errors.New("could not start worker pool")
+ }
+ logrus.Debugf("started media manager worker pool with %d workers and queue capacity of %d", numWorkers, queueSize)
+
+ return m, nil
+}
+
+func (m *manager) ProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) {
+ processingMedia, err := m.preProcessMedia(ctx, data, accountID, ai)
+ if err != nil {
+ return nil, err
+ }
+
+ logrus.Tracef("ProcessMedia: about to enqueue media with attachmentID %s, queue length is %d", processingMedia.AttachmentID(), m.pool.Queue())
+ m.pool.Enqueue(func(innerCtx context.Context) {
+ select {
+ case <-innerCtx.Done():
+ // if the inner context is done that means the worker pool is closing, so we should just return
+ return
+ default:
+ // start loading the media already for the caller's convenience
+ if _, err := processingMedia.LoadAttachment(innerCtx); err != nil {
+ logrus.Errorf("ProcessMedia: error processing media with attachmentID %s: %s", processingMedia.AttachmentID(), err)
+ }
+ }
+ })
+ logrus.Tracef("ProcessMedia: succesfully queued media with attachmentID %s, queue length is %d", processingMedia.AttachmentID(), m.pool.Queue())
+
+ return processingMedia, nil
+}
+
+func (m *manager) ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) {
+ processingEmoji, err := m.preProcessEmoji(ctx, data, shortcode, id, uri, ai)
+ if err != nil {
+ return nil, err
+ }
+
+ logrus.Tracef("ProcessEmoji: about to enqueue emoji with id %s, queue length is %d", processingEmoji.EmojiID(), m.pool.Queue())
+ m.pool.Enqueue(func(innerCtx context.Context) {
+ select {
+ case <-innerCtx.Done():
+ // if the inner context is done that means the worker pool is closing, so we should just return
+ return
+ default:
+ // start loading the emoji already for the caller's convenience
+ if _, err := processingEmoji.LoadEmoji(innerCtx); err != nil {
+ logrus.Errorf("ProcessEmoji: error processing emoji with id %s: %s", processingEmoji.EmojiID(), err)
+ }
+ }
+ })
+ logrus.Tracef("ProcessEmoji: succesfully queued emoji with id %s, queue length is %d", processingEmoji.EmojiID(), m.pool.Queue())
+
+ return processingEmoji, nil
+}
+
+func (m *manager) NumWorkers() int {
+ return m.numWorkers
+}
+
+func (m *manager) QueueSize() int {
+ return m.queueSize
+}
+
+func (m *manager) JobsQueued() int {
+ return m.pool.Queue()
+}
+
+func (m *manager) ActiveWorkers() int {
+ return m.pool.Workers()
+}
+
+func (m *manager) Stop() error {
+ logrus.Info("stopping media manager worker pool")
+
+ stopped := m.pool.Stop()
+ if !stopped {
+ return errors.New("could not stop media manager worker pool")
+ }
+ return nil
+}
diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go
new file mode 100644
index 000000000..a9419754c
--- /dev/null
+++ b/internal/media/manager_test.go
@@ -0,0 +1,363 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "testing"
+ "time"
+
+ "codeberg.org/gruf/go-store/kv"
+ "codeberg.org/gruf/go-store/storage"
+ "github.com/stretchr/testify/suite"
+ gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20211113114307_init"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+)
+
+type ManagerTestSuite struct {
+ MediaStandardTestSuite
+}
+
+func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() {
+ ctx := context.Background()
+
+ data := func(_ context.Context) (io.Reader, int, error) {
+ // load bytes from a test image
+ b, err := os.ReadFile("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
+ return bytes.NewBuffer(b), len(b), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ // process the media with no additional info provided
+ processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
+ suite.NoError(err)
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // do a blocking call to fetch the attachment
+ attachment, err := processingMedia.LoadAttachment(ctx)
+ suite.NoError(err)
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.EqualValues(gtsmodel.Original{
+ Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Original)
+ suite.EqualValues(gtsmodel.Small{
+ Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Small)
+ suite.Equal("image/jpeg", attachment.File.ContentType)
+ suite.Equal(269739, attachment.File.FileSize)
+ suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // make sure the processed file is in storage
+ processedFullBytes, err := suite.storage.Get(attachment.File.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytes)
+
+ // load the processed bytes from our test folder, to compare
+ processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytesExpected)
+
+ // the bytes in storage should be what we expected
+ suite.Equal(processedFullBytesExpected, processedFullBytes)
+
+ // now do the same for the thumbnail and make sure it's what we expected
+ processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytes)
+
+ processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytesExpected)
+
+ suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+}
+
+func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() {
+ ctx := context.Background()
+
+ data := func(_ context.Context) (io.Reader, int, error) {
+ // load bytes from a test image
+ b, err := os.ReadFile("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
+ return bytes.NewBuffer(b), len(b), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ // process the media with no additional info provided
+ processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
+ suite.NoError(err)
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // wait for the media to finish processing
+ for finished := processingMedia.Finished(); !finished; finished = processingMedia.Finished() {
+ time.Sleep(10 * time.Millisecond)
+ fmt.Printf("\n\nnot finished yet...\n\n")
+ }
+ fmt.Printf("\n\nfinished!\n\n")
+
+ // fetch the attachment from the database
+ attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.EqualValues(gtsmodel.Original{
+ Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Original)
+ suite.EqualValues(gtsmodel.Small{
+ Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Small)
+ suite.Equal("image/jpeg", attachment.File.ContentType)
+ suite.Equal(269739, attachment.File.FileSize)
+ suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // make sure the processed file is in storage
+ processedFullBytes, err := suite.storage.Get(attachment.File.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytes)
+
+ // load the processed bytes from our test folder, to compare
+ processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytesExpected)
+
+ // the bytes in storage should be what we expected
+ suite.Equal(processedFullBytesExpected, processedFullBytes)
+
+ // now do the same for the thumbnail and make sure it's what we expected
+ processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytes)
+
+ processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytesExpected)
+
+ suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+}
+
+func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() {
+ // in this test, we spam the manager queue with 50 new media requests, just to see how it holds up
+ ctx := context.Background()
+
+ b, err := os.ReadFile("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
+
+ data := func(_ context.Context) (io.Reader, int, error) {
+ // load bytes from a test image
+ return bytes.NewReader(b), len(b), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ spam := 50
+ inProcess := []*media.ProcessingMedia{}
+ for i := 0; i < spam; i++ {
+ // process the media with no additional info provided
+ processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
+ suite.NoError(err)
+ inProcess = append(inProcess, processingMedia)
+ }
+
+ for _, processingMedia := range inProcess {
+ fmt.Printf("\n\n\nactive workers: %d, queue length: %d\n\n\n", suite.manager.ActiveWorkers(), suite.manager.JobsQueued())
+
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // do a blocking call to fetch the attachment
+ attachment, err := processingMedia.LoadAttachment(ctx)
+ suite.NoError(err)
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.EqualValues(gtsmodel.Original{
+ Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Original)
+ suite.EqualValues(gtsmodel.Small{
+ Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Small)
+ suite.Equal("image/jpeg", attachment.File.ContentType)
+ suite.Equal(269739, attachment.File.FileSize)
+ suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // make sure the processed file is in storage
+ processedFullBytes, err := suite.storage.Get(attachment.File.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytes)
+
+ // load the processed bytes from our test folder, to compare
+ processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytesExpected)
+
+ // the bytes in storage should be what we expected
+ suite.Equal(processedFullBytesExpected, processedFullBytes)
+
+ // now do the same for the thumbnail and make sure it's what we expected
+ processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytes)
+
+ processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytesExpected)
+
+ suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ }
+}
+
+func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() {
+ ctx := context.Background()
+
+ data := func(_ context.Context) (io.Reader, int, error) {
+ // load bytes from a test image
+ b, err := os.ReadFile("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
+ return bytes.NewBuffer(b), len(b), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ temp := fmt.Sprintf("%s/gotosocial-test", os.TempDir())
+ defer os.RemoveAll(temp)
+
+ diskStorage, err := kv.OpenFile(temp, &storage.DiskConfig{
+ LockFile: path.Join(temp, "store.lock"),
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ diskManager, err := media.NewManager(suite.db, diskStorage)
+ if err != nil {
+ panic(err)
+ }
+ suite.manager = diskManager
+
+ // process the media with no additional info provided
+ processingMedia, err := diskManager.ProcessMedia(ctx, data, accountID, nil)
+ suite.NoError(err)
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // do a blocking call to fetch the attachment
+ attachment, err := processingMedia.LoadAttachment(ctx)
+ suite.NoError(err)
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.EqualValues(gtsmodel.Original{
+ Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Original)
+ suite.EqualValues(gtsmodel.Small{
+ Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
+ }, attachment.FileMeta.Small)
+ suite.Equal("image/jpeg", attachment.File.ContentType)
+ suite.Equal(269739, attachment.File.FileSize)
+ suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // make sure the processed file is in storage
+ processedFullBytes, err := diskStorage.Get(attachment.File.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytes)
+
+ // load the processed bytes from our test folder, to compare
+ processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedFullBytesExpected)
+
+ // the bytes in storage should be what we expected
+ suite.Equal(processedFullBytesExpected, processedFullBytes)
+
+ // now do the same for the thumbnail and make sure it's what we expected
+ processedThumbnailBytes, err := diskStorage.Get(attachment.Thumbnail.Path)
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytes)
+
+ processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
+ suite.NoError(err)
+ suite.NotEmpty(processedThumbnailBytesExpected)
+
+ suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+}
+
+func TestManagerTestSuite(t *testing.T) {
+ suite.Run(t, &ManagerTestSuite{})
+}
diff --git a/internal/media/media_test.go b/internal/media/media_test.go
new file mode 100644
index 000000000..f3e73ed79
--- /dev/null
+++ b/internal/media/media_test.go
@@ -0,0 +1,54 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media_test
+
+import (
+ "codeberg.org/gruf/go-store/kv"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type MediaStandardTestSuite struct {
+ suite.Suite
+
+ db db.DB
+ storage *kv.KVStore
+ manager media.Manager
+}
+
+func (suite *MediaStandardTestSuite) SetupSuite() {
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+}
+
+func (suite *MediaStandardTestSuite) SetupTest() {
+ testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
+ testrig.StandardDBSetup(suite.db, nil)
+ suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage)
+}
+
+func (suite *MediaStandardTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
diff --git a/internal/media/processicon.go b/internal/media/processicon.go
deleted file mode 100644
index 66cf1f999..000000000
--- a/internal/media/processicon.go
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/id"
- "github.com/superseriousbusiness/gotosocial/internal/uris"
-)
-
-func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string, remoteURL string) (*gtsmodel.MediaAttachment, error) {
- var isHeader bool
- var isAvatar bool
-
- switch mediaType {
- case TypeHeader:
- isHeader = true
- case TypeAvatar:
- isAvatar = true
- default:
- return nil, errors.New("header or avatar not selected")
- }
-
- var clean []byte
- var err error
-
- var original *imageAndMeta
- switch contentType {
- case MIMEJpeg:
- if clean, err = purgeExif(imageBytes); err != nil {
- return nil, fmt.Errorf("error cleaning exif data: %s", err)
- }
- original, err = deriveImage(clean, contentType)
- case MIMEPng:
- if clean, err = purgeExif(imageBytes); err != nil {
- return nil, fmt.Errorf("error cleaning exif data: %s", err)
- }
- original, err = deriveImage(clean, contentType)
- case MIMEGif:
- clean = imageBytes
- original, err = deriveGif(clean, contentType)
- default:
- return nil, errors.New("media type unrecognized")
- }
-
- if err != nil {
- return nil, fmt.Errorf("error parsing image: %s", err)
- }
-
- small, err := deriveThumbnail(clean, contentType, 256, 256)
- if err != nil {
- return nil, fmt.Errorf("error deriving thumbnail: %s", err)
- }
-
- // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it
- extension := strings.Split(contentType, "/")[1]
- newMediaID, err := id.NewRandomULID()
- if err != nil {
- return nil, err
- }
-
- originalURL := uris.GenerateURIForAttachment(accountID, string(mediaType), string(SizeOriginal), newMediaID, extension)
- smallURL := uris.GenerateURIForAttachment(accountID, string(mediaType), string(SizeSmall), newMediaID, extension)
- // we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", accountID, mediaType, SizeOriginal, newMediaID, extension)
- if err := mh.storage.Put(originalPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s.%s", accountID, mediaType, SizeSmall, newMediaID, extension)
- if err := mh.storage.Put(smallPath, small.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- ma := &gtsmodel.MediaAttachment{
- ID: newMediaID,
- StatusID: "",
- URL: originalURL,
- RemoteURL: remoteURL,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- Type: gtsmodel.FileTypeImage,
- FileMeta: gtsmodel.FileMeta{
- Original: gtsmodel.Original{
- Width: original.width,
- Height: original.height,
- Size: original.size,
- Aspect: original.aspect,
- },
- Small: gtsmodel.Small{
- Width: small.width,
- Height: small.height,
- Size: small.size,
- Aspect: small.aspect,
- },
- },
- AccountID: accountID,
- Description: "",
- ScheduledStatusID: "",
- Blurhash: small.blurhash,
- Processing: 2,
- File: gtsmodel.File{
- Path: originalPath,
- ContentType: contentType,
- FileSize: len(original.image),
- UpdatedAt: time.Now(),
- },
- Thumbnail: gtsmodel.Thumbnail{
- Path: smallPath,
- ContentType: contentType,
- FileSize: len(small.image),
- UpdatedAt: time.Now(),
- URL: smallURL,
- RemoteURL: "",
- },
- Avatar: isAvatar,
- Header: isHeader,
- }
-
- return ma, nil
-}
diff --git a/internal/media/processimage.go b/internal/media/processimage.go
deleted file mode 100644
index ca92c0660..000000000
--- a/internal/media/processimage.go
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/id"
- "github.com/superseriousbusiness/gotosocial/internal/uris"
-)
-
-func (mh *mediaHandler) processImageAttachment(data []byte, minAttachment *gtsmodel.MediaAttachment) (*gtsmodel.MediaAttachment, error) {
- var clean []byte
- var err error
- var original *imageAndMeta
- var small *imageAndMeta
-
- contentType := minAttachment.File.ContentType
-
- switch contentType {
- case MIMEJpeg, MIMEPng:
- if clean, err = purgeExif(data); err != nil {
- return nil, fmt.Errorf("error cleaning exif data: %s", err)
- }
- original, err = deriveImage(clean, contentType)
- if err != nil {
- return nil, fmt.Errorf("error parsing image: %s", err)
- }
- case MIMEGif:
- clean = data
- original, err = deriveGif(clean, contentType)
- if err != nil {
- return nil, fmt.Errorf("error parsing gif: %s", err)
- }
- default:
- return nil, errors.New("media type unrecognized")
- }
-
- small, err = deriveThumbnail(clean, contentType, 512, 512)
- if err != nil {
- return nil, fmt.Errorf("error deriving thumbnail: %s", err)
- }
-
- // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it
- extension := strings.Split(contentType, "/")[1]
- newMediaID, err := id.NewRandomULID()
- if err != nil {
- return nil, err
- }
-
- originalURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeOriginal), newMediaID, extension)
- smallURL := uris.GenerateURIForAttachment(minAttachment.AccountID, string(TypeAttachment), string(SizeSmall), newMediaID, "jpeg") // all thumbnails/smalls are encoded as jpeg
-
- // we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s.%s", minAttachment.AccountID, TypeAttachment, SizeOriginal, newMediaID, extension)
- if err := mh.storage.Put(originalPath, original.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- // and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s.jpeg", minAttachment.AccountID, TypeAttachment, SizeSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg
- if err := mh.storage.Put(smallPath, small.image); err != nil {
- return nil, fmt.Errorf("storage error: %s", err)
- }
-
- minAttachment.FileMeta.Original = gtsmodel.Original{
- Width: original.width,
- Height: original.height,
- Size: original.size,
- Aspect: original.aspect,
- }
-
- minAttachment.FileMeta.Small = gtsmodel.Small{
- Width: small.width,
- Height: small.height,
- Size: small.size,
- Aspect: small.aspect,
- }
-
- attachment := &gtsmodel.MediaAttachment{
- ID: newMediaID,
- StatusID: minAttachment.StatusID,
- URL: originalURL,
- RemoteURL: minAttachment.RemoteURL,
- CreatedAt: minAttachment.CreatedAt,
- UpdatedAt: minAttachment.UpdatedAt,
- Type: gtsmodel.FileTypeImage,
- FileMeta: minAttachment.FileMeta,
- AccountID: minAttachment.AccountID,
- Description: minAttachment.Description,
- ScheduledStatusID: minAttachment.ScheduledStatusID,
- Blurhash: small.blurhash,
- Processing: 2,
- File: gtsmodel.File{
- Path: originalPath,
- ContentType: contentType,
- FileSize: len(original.image),
- UpdatedAt: time.Now(),
- },
- Thumbnail: gtsmodel.Thumbnail{
- Path: smallPath,
- ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg
- FileSize: len(small.image),
- UpdatedAt: time.Now(),
- URL: smallURL,
- RemoteURL: minAttachment.Thumbnail.RemoteURL,
- },
- Avatar: minAttachment.Avatar,
- Header: minAttachment.Header,
- }
-
- return attachment, nil
-}
diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go
new file mode 100644
index 000000000..292712427
--- /dev/null
+++ b/internal/media/processingemoji.go
@@ -0,0 +1,291 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+ "time"
+
+ "codeberg.org/gruf/go-store/kv"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/uris"
+)
+
+// ProcessingEmoji represents an emoji currently processing. It exposes
+// various functions for retrieving data from the process.
+type ProcessingEmoji struct {
+ mu sync.Mutex
+
+ // id of this instance's account -- pinned for convenience here so we only need to fetch it once
+ instanceAccountID string
+
+ /*
+ below fields should be set on newly created media;
+ emoji will be updated incrementally as media goes through processing
+ */
+
+ emoji *gtsmodel.Emoji
+ data DataFunc
+ read bool // bool indicating that data function has been triggered already
+
+ /*
+ below fields represent the processing state of the static of the emoji
+ */
+
+ staticState processState
+ fullSizeState processState
+
+ /*
+ below pointers to database and storage are maintained so that
+ the media can store and update itself during processing steps
+ */
+
+ database db.DB
+ storage *kv.KVStore
+
+ err error // error created during processing, if any
+
+ // track whether this emoji has already been put in the databse
+ insertedInDB bool
+}
+
+// EmojiID returns the ID of the underlying emoji without blocking processing.
+func (p *ProcessingEmoji) EmojiID() string {
+ return p.emoji.ID
+}
+
+// LoadEmoji blocks until the static and fullsize image
+// has been processed, and then returns the completed emoji.
+func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if err := p.store(ctx); err != nil {
+ return nil, err
+ }
+
+ if err := p.loadStatic(ctx); err != nil {
+ return nil, err
+ }
+
+ // store the result in the database before returning it
+ if !p.insertedInDB {
+ if err := p.database.Put(ctx, p.emoji); err != nil {
+ return nil, err
+ }
+ p.insertedInDB = true
+ }
+
+ return p.emoji, nil
+}
+
+// Finished returns true if processing has finished for both the thumbnail
+// and full fized version of this piece of media.
+func (p *ProcessingEmoji) Finished() bool {
+ return p.staticState == complete && p.fullSizeState == complete
+}
+
+func (p *ProcessingEmoji) loadStatic(ctx context.Context) error {
+ switch p.staticState {
+ case received:
+ // stream the original file out of storage...
+ stored, err := p.storage.GetStream(p.emoji.ImagePath)
+ if err != nil {
+ p.err = fmt.Errorf("loadStatic: error fetching file from storage: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ // we haven't processed a static version of this emoji yet so do it now
+ static, err := deriveStaticEmoji(stored, p.emoji.ImageContentType)
+ if err != nil {
+ p.err = fmt.Errorf("loadStatic: error deriving static: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadStatic: error closing stored full size: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ // put the static in storage
+ if err := p.storage.Put(p.emoji.ImageStaticPath, static.small); err != nil {
+ p.err = fmt.Errorf("loadStatic: error storing static: %s", err)
+ p.staticState = errored
+ return p.err
+ }
+
+ p.emoji.ImageStaticFileSize = len(static.small)
+
+ // we're done processing the static version of the emoji!
+ p.staticState = complete
+ fallthrough
+ case complete:
+ return nil
+ case errored:
+ return p.err
+ }
+
+ return fmt.Errorf("static processing status %d unknown", p.staticState)
+}
+
+// store calls the data function attached to p if it hasn't been called yet,
+// and updates the underlying attachment fields as necessary. It will then stream
+// bytes from p's reader directly into storage so that it can be retrieved later.
+func (p *ProcessingEmoji) store(ctx context.Context) error {
+ // check if we've already done this and bail early if we have
+ if p.read {
+ return nil
+ }
+
+ // execute the data function to get the reader out of it
+ reader, fileSize, err := p.data(ctx)
+ if err != nil {
+ return fmt.Errorf("store: error executing data function: %s", err)
+ }
+
+ // extract no more than 261 bytes from the beginning of the file -- this is the header
+ firstBytes := make([]byte, maxFileHeaderBytes)
+ if _, err := reader.Read(firstBytes); err != nil {
+ return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err)
+ }
+
+ // now we have the file header we can work out the content type from it
+ contentType, err := parseContentType(firstBytes)
+ if err != nil {
+ return fmt.Errorf("store: error parsing content type: %s", err)
+ }
+
+ // bail if this is a type we can't process
+ if !supportedEmoji(contentType) {
+ return fmt.Errorf("store: content type %s was not valid for an emoji", contentType)
+ }
+
+ // extract the file extension
+ split := strings.Split(contentType, "/")
+ extension := split[1] // something like 'gif'
+
+ // set some additional fields on the emoji now that
+ // we know more about what the underlying image actually is
+ p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), p.emoji.ID, extension)
+ p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, p.emoji.ID, extension)
+ p.emoji.ImageContentType = contentType
+ p.emoji.ImageFileSize = fileSize
+
+ // concatenate the first bytes with the existing bytes still in the reader (thanks Mara)
+ multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader)
+
+ // store this for now -- other processes can pull it out of storage as they please
+ if err := p.storage.PutStream(p.emoji.ImagePath, multiReader); err != nil {
+ return fmt.Errorf("store: error storing stream: %s", err)
+ }
+
+ // if the original reader is a readcloser, close it since we're done with it now
+ if rc, ok := reader.(io.ReadCloser); ok {
+ if err := rc.Close(); err != nil {
+ return fmt.Errorf("store: error closing readcloser: %s", err)
+ }
+ }
+
+ p.read = true
+ return nil
+}
+
+func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) {
+ instanceAccount, err := m.db.GetInstanceAccount(ctx, "")
+ if err != nil {
+ return nil, fmt.Errorf("preProcessEmoji: error fetching this instance account from the db: %s", err)
+ }
+
+ // populate initial fields on the emoji -- some of these will be overwritten as we proceed
+ emoji := &gtsmodel.Emoji{
+ ID: id,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Shortcode: shortcode,
+ Domain: "", // assume our own domain unless told otherwise
+ ImageRemoteURL: "",
+ ImageStaticRemoteURL: "",
+ ImageURL: "", // we don't know yet
+ ImageStaticURL: uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), id, mimePng), // all static emojis are encoded as png
+ ImagePath: "", // we don't know yet
+ ImageStaticPath: fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeStatic, id, mimePng), // all static emojis are encoded as png
+ ImageContentType: "", // we don't know yet
+ ImageStaticContentType: mimeImagePng, // all static emojis are encoded as png
+ ImageFileSize: 0,
+ ImageStaticFileSize: 0,
+ ImageUpdatedAt: time.Now(),
+ Disabled: false,
+ URI: uri,
+ VisibleInPicker: true,
+ CategoryID: "",
+ }
+
+ // check if we have additional info to add to the emoji,
+ // and overwrite some of the emoji fields if so
+ if ai != nil {
+ if ai.CreatedAt != nil {
+ emoji.CreatedAt = *ai.CreatedAt
+ }
+
+ if ai.Domain != nil {
+ emoji.Domain = *ai.Domain
+ }
+
+ if ai.ImageRemoteURL != nil {
+ emoji.ImageRemoteURL = *ai.ImageRemoteURL
+ }
+
+ if ai.ImageStaticRemoteURL != nil {
+ emoji.ImageStaticRemoteURL = *ai.ImageStaticRemoteURL
+ }
+
+ if ai.Disabled != nil {
+ emoji.Disabled = *ai.Disabled
+ }
+
+ if ai.VisibleInPicker != nil {
+ emoji.VisibleInPicker = *ai.VisibleInPicker
+ }
+
+ if ai.CategoryID != nil {
+ emoji.CategoryID = *ai.CategoryID
+ }
+ }
+
+ processingEmoji := &ProcessingEmoji{
+ instanceAccountID: instanceAccount.ID,
+ emoji: emoji,
+ data: data,
+ staticState: received,
+ fullSizeState: received,
+ database: m.db,
+ storage: m.storage,
+ }
+
+ return processingEmoji, nil
+}
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
new file mode 100644
index 000000000..0bbe35aee
--- /dev/null
+++ b/internal/media/processingmedia.go
@@ -0,0 +1,410 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+ "time"
+
+ "codeberg.org/gruf/go-store/kv"
+ terminator "github.com/superseriousbusiness/exif-terminator"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/uris"
+)
+
+// ProcessingMedia represents a piece of media that is currently being processed. It exposes
+// various functions for retrieving data from the process.
+type ProcessingMedia struct {
+ mu sync.Mutex
+
+ /*
+ below fields should be set on newly created media;
+ attachment will be updated incrementally as media goes through processing
+ */
+
+ attachment *gtsmodel.MediaAttachment
+ data DataFunc
+ read bool // bool indicating that data function has been triggered already
+
+ thumbstate processState // the processing state of the media thumbnail
+ fullSizeState processState // the processing state of the full-sized media
+
+ /*
+ below pointers to database and storage are maintained so that
+ the media can store and update itself during processing steps
+ */
+
+ database db.DB
+ storage *kv.KVStore
+
+ err error // error created during processing, if any
+
+ // track whether this media has already been put in the databse
+ insertedInDB bool
+}
+
+// AttachmentID returns the ID of the underlying media attachment without blocking processing.
+func (p *ProcessingMedia) AttachmentID() string {
+ return p.attachment.ID
+}
+
+// LoadAttachment blocks until the thumbnail and fullsize content
+// has been processed, and then returns the completed attachment.
+func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if err := p.store(ctx); err != nil {
+ return nil, err
+ }
+
+ if err := p.loadThumb(ctx); err != nil {
+ return nil, err
+ }
+
+ if err := p.loadFullSize(ctx); err != nil {
+ return nil, err
+ }
+
+ // store the result in the database before returning it
+ if !p.insertedInDB {
+ if err := p.database.Put(ctx, p.attachment); err != nil {
+ return nil, err
+ }
+ p.insertedInDB = true
+ }
+
+ return p.attachment, nil
+}
+
+// Finished returns true if processing has finished for both the thumbnail
+// and full fized version of this piece of media.
+func (p *ProcessingMedia) Finished() bool {
+ return p.thumbstate == complete && p.fullSizeState == complete
+}
+
+func (p *ProcessingMedia) loadThumb(ctx context.Context) error {
+ switch p.thumbstate {
+ case received:
+ // we haven't processed a thumbnail for this media yet so do it now
+
+ // check if we need to create a blurhash or if there's already one set
+ var createBlurhash bool
+ if p.attachment.Blurhash == "" {
+ // no blurhash created yet
+ createBlurhash = true
+ }
+
+ // stream the original file out of storage...
+ stored, err := p.storage.GetStream(p.attachment.File.Path)
+ if err != nil {
+ p.err = fmt.Errorf("loadThumb: error fetching file from storage: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // ... and into the derive thumbnail function
+ thumb, err := deriveThumbnail(stored, p.attachment.File.ContentType, createBlurhash)
+ if err != nil {
+ p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadThumb: error closing stored full size: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // put the thumbnail in storage
+ if err := p.storage.Put(p.attachment.Thumbnail.Path, thumb.small); err != nil {
+ p.err = fmt.Errorf("loadThumb: error storing thumbnail: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // set appropriate fields on the attachment based on the thumbnail we derived
+ if createBlurhash {
+ p.attachment.Blurhash = thumb.blurhash
+ }
+ p.attachment.FileMeta.Small = gtsmodel.Small{
+ Width: thumb.width,
+ Height: thumb.height,
+ Size: thumb.size,
+ Aspect: thumb.aspect,
+ }
+ p.attachment.Thumbnail.FileSize = len(thumb.small)
+
+ // we're done processing the thumbnail!
+ p.thumbstate = complete
+ fallthrough
+ case complete:
+ return nil
+ case errored:
+ return p.err
+ }
+
+ return fmt.Errorf("loadThumb: thumbnail processing status %d unknown", p.thumbstate)
+}
+
+func (p *ProcessingMedia) loadFullSize(ctx context.Context) error {
+ switch p.fullSizeState {
+ case received:
+ var err error
+ var decoded *imageMeta
+
+ // stream the original file out of storage...
+ stored, err := p.storage.GetStream(p.attachment.File.Path)
+ if err != nil {
+ p.err = fmt.Errorf("loadFullSize: error fetching file from storage: %s", err)
+ p.fullSizeState = errored
+ return p.err
+ }
+
+ // decode the image
+ ct := p.attachment.File.ContentType
+ switch ct {
+ case mimeImageJpeg, mimeImagePng:
+ decoded, err = decodeImage(stored, ct)
+ case mimeImageGif:
+ decoded, err = decodeGif(stored)
+ default:
+ err = fmt.Errorf("loadFullSize: content type %s not a processible image type", ct)
+ }
+
+ if err != nil {
+ p.err = err
+ p.fullSizeState = errored
+ return p.err
+ }
+
+ if err := stored.Close(); err != nil {
+ p.err = fmt.Errorf("loadFullSize: error closing stored full size: %s", err)
+ p.thumbstate = errored
+ return p.err
+ }
+
+ // set appropriate fields on the attachment based on the image we derived
+ p.attachment.FileMeta.Original = gtsmodel.Original{
+ Width: decoded.width,
+ Height: decoded.height,
+ Size: decoded.size,
+ Aspect: decoded.aspect,
+ }
+ p.attachment.File.UpdatedAt = time.Now()
+ p.attachment.Processing = gtsmodel.ProcessingStatusProcessed
+
+ // we're done processing the full-size image
+ p.fullSizeState = complete
+ fallthrough
+ case complete:
+ return nil
+ case errored:
+ return p.err
+ }
+
+ return fmt.Errorf("loadFullSize: full size processing status %d unknown", p.fullSizeState)
+}
+
+// store calls the data function attached to p if it hasn't been called yet,
+// and updates the underlying attachment fields as necessary. It will then stream
+// bytes from p's reader directly into storage so that it can be retrieved later.
+func (p *ProcessingMedia) store(ctx context.Context) error {
+ // check if we've already done this and bail early if we have
+ if p.read {
+ return nil
+ }
+
+ // execute the data function to get the reader out of it
+ reader, fileSize, err := p.data(ctx)
+ if err != nil {
+ return fmt.Errorf("store: error executing data function: %s", err)
+ }
+
+ // extract no more than 261 bytes from the beginning of the file -- this is the header
+ firstBytes := make([]byte, maxFileHeaderBytes)
+ if _, err := reader.Read(firstBytes); err != nil {
+ return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err)
+ }
+
+ // now we have the file header we can work out the content type from it
+ contentType, err := parseContentType(firstBytes)
+ if err != nil {
+ return fmt.Errorf("store: error parsing content type: %s", err)
+ }
+
+ // bail if this is a type we can't process
+ if !supportedImage(contentType) {
+ return fmt.Errorf("store: media type %s not (yet) supported", contentType)
+ }
+
+ // extract the file extension
+ split := strings.Split(contentType, "/")
+ if len(split) != 2 {
+ return fmt.Errorf("store: content type %s was not valid", contentType)
+ }
+ extension := split[1] // something like 'jpeg'
+
+ // concatenate the cleaned up first bytes with the existing bytes still in the reader (thanks Mara)
+ multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), reader)
+
+ // we'll need to clean exif data from the first bytes; while we're
+ // here, we can also use the extension to derive the attachment type
+ var clean io.Reader
+ switch extension {
+ case mimeGif:
+ p.attachment.Type = gtsmodel.FileTypeGif
+ clean = multiReader // nothing to clean from a gif
+ case mimeJpeg, mimePng:
+ p.attachment.Type = gtsmodel.FileTypeImage
+ purged, err := terminator.Terminate(multiReader, fileSize, extension)
+ if err != nil {
+ return fmt.Errorf("store: exif error: %s", err)
+ }
+ clean = purged
+ default:
+ return fmt.Errorf("store: couldn't process %s", extension)
+ }
+
+ // now set some additional fields on the attachment since
+ // we know more about what the underlying media actually is
+ p.attachment.URL = uris.GenerateURIForAttachment(p.attachment.AccountID, string(TypeAttachment), string(SizeOriginal), p.attachment.ID, extension)
+ p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension)
+ p.attachment.File.ContentType = contentType
+ p.attachment.File.FileSize = fileSize
+
+ // store this for now -- other processes can pull it out of storage as they please
+ if err := p.storage.PutStream(p.attachment.File.Path, clean); err != nil {
+ return fmt.Errorf("store: error storing stream: %s", err)
+ }
+
+ // if the original reader is a readcloser, close it since we're done with it now
+ if rc, ok := reader.(io.ReadCloser); ok {
+ if err := rc.Close(); err != nil {
+ return fmt.Errorf("store: error closing readcloser: %s", err)
+ }
+ }
+
+ p.read = true
+ return nil
+}
+
+func (m *manager) preProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) {
+ id, err := id.NewRandomULID()
+ if err != nil {
+ return nil, err
+ }
+
+ file := gtsmodel.File{
+ Path: "", // we don't know yet because it depends on the uncalled DataFunc
+ ContentType: "", // we don't know yet because it depends on the uncalled DataFunc
+ UpdatedAt: time.Now(),
+ }
+
+ thumbnail := gtsmodel.Thumbnail{
+ URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg,
+ Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg,
+ ContentType: mimeJpeg,
+ UpdatedAt: time.Now(),
+ }
+
+ // populate initial fields on the media attachment -- some of these will be overwritten as we proceed
+ attachment := &gtsmodel.MediaAttachment{
+ ID: id,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ StatusID: "",
+ URL: "", // we don't know yet because it depends on the uncalled DataFunc
+ RemoteURL: "",
+ Type: gtsmodel.FileTypeUnknown, // we don't know yet because it depends on the uncalled DataFunc
+ FileMeta: gtsmodel.FileMeta{},
+ AccountID: accountID,
+ Description: "",
+ ScheduledStatusID: "",
+ Blurhash: "",
+ Processing: gtsmodel.ProcessingStatusReceived,
+ File: file,
+ Thumbnail: thumbnail,
+ Avatar: false,
+ Header: false,
+ }
+
+ // check if we have additional info to add to the attachment,
+ // and overwrite some of the attachment fields if so
+ if ai != nil {
+ if ai.CreatedAt != nil {
+ attachment.CreatedAt = *ai.CreatedAt
+ }
+
+ if ai.StatusID != nil {
+ attachment.StatusID = *ai.StatusID
+ }
+
+ if ai.RemoteURL != nil {
+ attachment.RemoteURL = *ai.RemoteURL
+ }
+
+ if ai.Description != nil {
+ attachment.Description = *ai.Description
+ }
+
+ if ai.ScheduledStatusID != nil {
+ attachment.ScheduledStatusID = *ai.ScheduledStatusID
+ }
+
+ if ai.Blurhash != nil {
+ attachment.Blurhash = *ai.Blurhash
+ }
+
+ if ai.Avatar != nil {
+ attachment.Avatar = *ai.Avatar
+ }
+
+ if ai.Header != nil {
+ attachment.Header = *ai.Header
+ }
+
+ if ai.FocusX != nil {
+ attachment.FileMeta.Focus.X = *ai.FocusX
+ }
+
+ if ai.FocusY != nil {
+ attachment.FileMeta.Focus.Y = *ai.FocusY
+ }
+ }
+
+ processingMedia := &ProcessingMedia{
+ attachment: attachment,
+ data: data,
+ thumbstate: received,
+ fullSizeState: received,
+ database: m.db,
+ storage: m.storage,
+ }
+
+ return processingMedia, nil
+}
diff --git a/internal/media/processvideo.go b/internal/media/processvideo.go
deleted file mode 100644
index d0d11f779..000000000
--- a/internal/media/processvideo.go
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-// func (mh *mediaHandler) processVideoAttachment(data []byte, accountID string, contentType string, remoteURL string) (*gtsmodel.MediaAttachment, error) {
-// return nil, nil
-// }
diff --git a/internal/media/test/test-corrupted.jpg b/internal/media/test/test-corrupted.jpg
deleted file mode 100644
index 86e4d75ce..000000000
--- a/internal/media/test/test-corrupted.jpg
+++ /dev/null
@@ -1 +0,0 @@
-3BHI03zDX4cEuh#Ak%Ag~GOs8Q#dXdu9zH*51jjoO%FYNf*wa&!G-*uN&iLPb0%^FtLLqcQX6x4CorRP82Q8AYsLi!uL!lyX&u^w6xUiwmX*vX^S#waa_A6&CnDO7rXO%3ICVdmGSaUcaVfD2oki4LQO9*b~YM$-@*i2^BnFVPdKw1Nyt9xb$QK3*um4CHpWi^!t#GL8C-VnAOU2Sr6hThro6HA9LBR6nM_oc~bfxR789@mrsG6hH5ZM%uO1IGmHJX4fq!HyL0iYLv*wvhUWqztsyxwXPpyDnZN~PA!uG#*wSxKoVwMITI5hKGEEGk5BK4W~z80WzBr2s~D%Gzks5SNG2hIT@5lQowr0&DWCi2BwDpN0r4P0F2mmds_teB$NoTllvH4vnJ&$C#M5DBXDJdUgI4iZ$G_ZQZLxYW%TzO~2_-vwQLP1J3Y-aXsXlPNCbNmoz1BvQkx*UUP~Nn6sLIrrzarKLtyo2Zb5B584~rA24AN8NV-6!XTS2DP00&$bwRVYOB79sdYwpHaw!Gu&W@vf~TPVTLA~jmals4#~OQuOH%uaL2boZ~_@_81FI3I_Jx_ida9Fzr7z6WxNNFeOEAMBP98b_f6wtmbcFxPen#PHzeDj*xPbdnP^iLMY~zT^YWYICGaR~A0hx_oVtT#V*1-IgW@9TtQDcIe#SiU8jpKEp5nwJ@c6heA2wmPdru!P8b#H2uOokHZd$sGgx&V6zO90S8&kNUjD^D~wsYhZ0p~B2bOa!OimhA^GV440EqQ6o6HWzEGp*zyQ48jXB$LZyzbUtLx7SZw$PmvtcK@4DtZOwyG5fp0Y_6YC~zGYWbLaCVIDy#dBfGMFtCXg-TxKXVk~PHm3@a3po-*6DY2L@PKQp8YZqgB%PgnlNUYHpSryaSvsh$G4C*UW%MkiyC_TScXLYdgyLV@#oG~8U$kA4$*rrbShcL#gi#dr@DBOXO$o0n^p0-nu$mBQI2qYdP%FympbjhK6MI&~U^KQpFcYjXp^*#Op4YaKP4-O3Ibs889Lgr8*NroQ8$2nr^rK3sgOlydjUdi%j_u@YPQzJJDYgxr*Z*qivABBMLLqXv6T^ZAllo3%d3iyW9uOlsrB~2rG$zKLUAnP98VjR!MV1^vm@L~eobU7~&fJw~o1@uj1sokB4KnVk&@_YVkYWu*2fcUY6fvNx~!y09*e@nU1oay0Ai&XGSKw6n2T4cL_Pgtf6XtGgXH~wiWkVL&6LKbO38P9poIX#01w17xVbKfZAoFwxD8$1l9VV-WwI@404Z-EJoDCE2!kGMcOfdrYJhBu7#vJ*aOVO4Lgxoh7ybUefdC0DHht51deeEgWxNq8npu0%pMvu2dPM&Cnx#knz2CO9GQL#A%wB6fXBx-dit$Wf@ii%#~ttUon&eYP&iFLC*475CWN8&cMg#4i~dS*O4mJVD@xELn3LzuKJk-W7Pkj~5NZlhVL0pr_RH5Oic7GMWK&rsB~FY-%PGqpS6E4Rmhr~*^n@I7EY&FvTHn*1gJ@m139p_hT3Gh6uteFxVhlD9r471l1&#sJ6b9aut5yFHhxsVAvMeI5i1A!P0$-BGsegn8YpjNf15Ce0Dz%Wb7NYp5eY2J!dRZS8Tld!e1_72ER8KUIW%&NKGbL09Z~!F7322O^wjCaV~49jxCu6c1Dqdg0ZO^iT0#mkg@BwAytt^c%H^bI45mkegz0btunQ6zJv0Ecypsf99sGsIomRB#rbluVc1mU*DjA&Y-WV-XhzI$^RCSdH2k8jTRZRv3w7%3kNLQ47Q*p$brF-Zqtv0pkMe^a!cmZ@jDx##^6893Uuv_iH^#sA1XeMvE50wsP#gD0w2LbEh0iq_NMe%e~x^AO3kK8UbVBd2y*zQ!-y-o-^0sZ5xe#*BDJk$aD8#8Or*&0Jr8A$-Z^0Mx1awGh55e%69Di^JAjr3FE9B_ZpoH*uha%SxjPJWRU%ElstN!7L4z#@1ReWdY8QvdfFVUe_zNnP_pKcI8iW&92Eh@IVVH~6EJT$tlUeiz1QLKFE&f5PLKwXNl#trj&@cmKfJ87mMNRb6e@zWeOXr9&%3KSpo9&_6kAoOgHh@f-%D@GOVTf7AWqIc8m9BNd0EE_@ApS44Y-g$*#s_bO3%B^G_er05bqNxoQ$H@GAlUnhXcgo@sY2SxR1xm@51CL^*sYupBf!OcqiC~fKytFZWl&mh0$F3w1n!CfrmD3htCLFx-X3eV5&oG^smNVB_*#aLQuhC#9@@k9MccBwshe5WpklDEGAx%_2Y4CBi3ne^V*BqRGlxLR^OnW#tD-6rrv6ivt_4NOvHOUyC~86TQ%bh4QqWJ-ali$Hf@OV59O67I#ZdBEp6vA0hFJm^RIk6J3!Xlpqdhik9mcTPznjXknpMW*N4&nVT~v5jVW5-XY^b-fzaUNh5Ej1QaeaR#d*nEzyUlXTCd7%im_HByP30gNr!aX69m6Tsexd*oTk&6COPu0TM9F*rWqUXTgR^S9jF3JcFwZ_BXZ@i4Q61*6rm^rQnKEFSGV4^f1^E0__!LqIXT^v^2%Wj*1HHhCEisrsg$sfoz1G1JcKhV!B^q!8mz1c8CWg~*cC!FRAZQTz@OiUyPf0dbduR#ydk0DA@d_YCB$$$dfprxW0vmEY@z56s22$BsF80HS*n@QpclLKJX8*OPr_Oj!tUpR4TV1^_DDYG_#HAFBo-vX*IEQmn58HV9AFjxxQ$9H1E2NsbsGzT50d379K6AQ&#RE-KTg&hwdnS&K*-qLcrdqX6@RN&-a-%D@VNh^7r2Eb^oKJ*kj2apNKnc^eJ-kUb*YtjMyK^#fyaDsMBcp*Qk@bd-Dm6Z*TgGJF2KPBuyQP!shfB2%qQk__b~!cw3$U&eIOZceV5wQ4u@JtTuB9W6RqR6ZqA3Ct4PNxw^z~aEB!wZ_ldow75#VWM407d@3m2Xntl@@j9NWRcR-KgqWK8TaVf8F_rbz1O4XYyy4Vg%QnPQcDEZ_$pBuaIv^vHVFKYVzKFoULJp!UyMKog2mnJQmA@IPis$F3oTL^RNm_45&nb3Y6Mx$OXZy&8c-7C2CLpv2m52LRb^JcaCJH3eoB0AyKhCQj%isfr~s*5GJ!jjd!lzxzW1qTS6sp2jn#s@UFYxoz#&@Pk_n&dwR&5^aZGADDfNRQV0_kL7_ECmaXsKDfV*J*pFBr@*tui4ndv!rv!KU5FsuT#nQJnOAejYweHIoq#Z^ML~LXh_iBEs5!4&ej-^DGPzpMyEtB9y_~Hp4%l&av0lnXeYFQtA^3UZ4cmw0UGo9QjOQyN*-JBOZdGPDWpmEuFp#F5W!4^OOdzmXw~9%Eg4WZkFJ6&n^8NVguUkm92eUP6X2BN#X0@A$0U@D^OZpq&BYL^iFm9u4nO01gyRQTJlaenYvZZEV!3%Fur#Y6CISyxq-DuxMQL8sw1qPY~^yBO@HN-@4JKJBKOIFi27JtZ%@p$hFseaWW*prM&9!5IBV7^xsv@BJVJ5YYS#5OEd_qtb8edjytgO8Sy4AX16W2JmoRr#3dcjmxmVrvvPHy*Ouo0WAmo559GcW3rgH4jFi-_UH6lJN!_sIASJYvEI1lSm!9gDylbqc#HJjPJm$4b_fl5aN5jkB0Zf^dB4vEFS0FatCHx!siu6XJEl3rkM!I&kqmQxTxQN3nuZklhifiaV^&3Cj8RI5CVi7S%$khEP_8F2_@*YJJXC5Ng6oFBF7@~Hk$hDG9dh7MirrW7%PMNQ~alVwn-Hj%OdtnlLKliK_t^yimq-DcV$ZgFVH7i%&jvcfD!!LuN8kF6h%OG5-_~1gmv06xovdUiqk!WFl^Kt&RkEVBo1WxtGQKWR&ulcWxVYmjn#sg%9v76#f3tNc!C~^zze&igwVzDwgbMlzV#skwi!kBn$nnfXV8Dm9%3D@0Dp_3G2!*kr7@tiJpM&P_j9F2FK2~YVrDHPHkjZp!efZif*0oE3NnQN5qCzsZ#&!hB4bkrt&#igE#yQKsANt6oG6$lllQ09UXmA2X48nV@RICX3I6AhmB9Q1XKrEUnaj^0SIs#0EqU_KCWWEAtYJg$a1da6laTWjrF@-m-%rl5F8H23pYRvusRkdgm~uD~td7RHVPg3zXXrc76RfdSS8aAsSBmjm_4oSd!^1Io&lkk&M9pfPmdteYzAqKw%cb*~FY9A%3&-1uM!oi%W1bUzoxBWpi$ljTXK4&Pr*cA2qUFNkZW1jUAlphk9WXF1c&!eYuTD$*_JV#jL%2yfRG7aZDFtVbersd%iFJg07XtW_J74irWzW#ft0sq-NW@v-DTg$#s~@c_xeXPFJfyu^8ai67iTcTpp#V~EUeKepk60Cs7RxKLz#mdhbdma~Hf8PEj1Y1Azwfl9HJoyvCUfENVYAWVZIALy2v618ZvTiQCO18*$96y$NP_LthuS~Z$1CG1%AGjpqXWqouhktfz!SPoVw-xr6rpRh0^oxwjaTup7No~H!o4*k-YsLlW2WQ0jTNh3gXm5NGrz*3M$fjjEfJMsvS7ARActzuzgAZm9_mi9n%-vf6zCP@NZV%1CY@bDVd@7u#6bK#1P$k5*XRB4Wj5L0yC#pKY*W&rv!jGUQ%OsDo~MbGWCPuYLIZaL6#sX$lT9p1c9g-I5~xCr%!@J7IbX#Y_fy_WBgZxjD21H213Y#*A!e#s&u1@Gx^R@_ngUUZg-iWDmS~$T8VA*sWNDv~F$G2xvFTZ%Z#DGrh4xLMWg~fSt--EOhi_qWk1xSnt!9AFAoZOBY-A5OIwJ9gfRUzaMR1Yk$0irw-2mJRzrHbP8d2CcI~CWkJ_g8hkbdQQsgheWe5oZHWN8S!T5q00Nw1lCqnx7MhzZq&Oi@0UBosF%_aIvQDFxQH!TmWimN2EZ%0#5@mJ_FL5J^0!ZahIbKfb&76dT4Qs*uvuNzwb^eIgcIn~AMhuB-rNfFhjQEm@YmAcCJzzgPBag#yNSeBswux&^g1WytSp-a6z1_0gr3_k~eYADTNCn6KeB6dVQ*im0stw-HtExVZ@0MYEUJWNqu8dC^AJ7t00IP1Fuf9go&*fNvrk0fK#KUBEOkTdHstq_QeOvtmE4bI@@odg1EUDJQLRY~cCGNiFBXVBHp&#5_Z3J~sNXkH_~F~&6wXVLo-dM*ND_q!c0a98J&NkbbuNdfVq4T@8es3qLIPF4*RV@vYjR~NkiQcaBf1CIOY6lczE%fslmjMJKca_x3Hovtd4oPGhOOEwm63TFx@YJMn@21*MGk_60-8-jOi#@qytjxGrdrewet66!Mm74DuxtqQr!2@ku3iktxORbE7GKT~SCZWj0HL~$EQY1c5vYduZ_77Pqrh2Mfru93KcYyE$-QZfMzxBwrKLfJaHh!uXZYfSoTv_GFYYTENDGjZAZlWOc81g4%cPTg_fQ#-JzzS0NqO4nJ1jF95yJ42K8D@cd%%DCDZ~MaLJshEhnmEpSv_y@55M%OWGorW6PjOyC4r2!nLbRFSFdRdu2nUT~^o3OE7PIxt1#tZjjMT3uh9ZSzXxlUrmKO*&q@D-RNnhdNmrQk1T!uKQC^xX!~r2lh!~DhJv73j-8yPf5g8b&62A0tDhup0qCA7ITH3296H4ZvBLBKWwRZT6MgYbYdXASbx!A^9tCWPHHnBpP0SU#rht1gsC4xk@T9*Jd9BHNlquFfQ5NPU191lZ22pUu&G~nPmB$zW9K4vbOcYqV8cp$ShUOu92KW&K0fsRkckkSNgJNE3AJmBmMtr@TuADy-OK&^G7RNVe31nqD*bsvQI-~hcb*CnVuf!HMBFaxfEapVsAMTMf*qGg5PdzKUMDl#xSP*n9A@9%ip&eZmk-605hHeRHarPgUdQEqimpzsRZL0^eEoNCxx*5@zTXQo@*kbuh0c*mybPBEh1tKi&kJi76hRqqaZaP#tBsnO2rzYM!MHa$QZfs9Nd@VgOBo&7tvvUQtGhTVUr&ulSGG$d%*bXQjzyOfXmAtho-SH*2!1#9hY6dQ51eUtOMcKNA6-9OAI_gmPBRBuKRKJyW2A6Iyr0vbkp-87O7_lmst15jDf&0_M_smSAyvgL%n-q3K9AVpxLghgld9$iMfrB@d8N&33wmGDcXt@NXk~7^&LUuGeT15DauP&Bj0014LOg$vkm9U6P#PyL@m~Qtbr79ULoT^hSJBMZWWzjp$F-$%mY0EPghUAtYlAeq#^II4_fJNZmJGc%d-h1$Du^n4uGa@**0uMX_D@sb3Pcuzb3tQ0AhZcQNa0iMQc5DOV~yF6VJ8&qhnVXh$68pTO4qxrW&LNDbex%_LigONjXNPuPbsbbWUsUe1P^YbKjpy0JatODmHSNZ1IF~AA-uhc6q~n7UH@tATI%~qkdFO4ch6onp8F%&9-wjUAseD5xlG138m~5nMcTnE0qaHx25bOd~emMw3ZqSyRtxn~9~ACCd6sl4wOHuzQlALJZBo^yZzcx_lyKt0b^CDBME5nF81wwTN39Zk9qL*F%&&nBO5xq&uNomBszQsKcQJx*e2G99gK@IpzQk!1%Eoc!oV2iex@yfcl-z^_z!aS7aJDVm%r28o3LEDn-yQvv4i~r43_O4!LcNJIKvF63wNyhiM4EAOHmkjs#i^r3t1#7MjaU^@zLFhCMo1*5Y2d&jjZoqnSrEj^js#~TCWbKi#2xrZA4m&~u-!z~15aX4c8E7qhTle5-b-Yz@Q88i&Kn0Aaa$*sQg2jBbXTZg6XzD-2Pd__cg06AQ@zE#Obwi-CdtpvJW~g7SEaOz$88D0RQun4d~F7k5Yw*CstxYJseCm6d3r&NuzIh&awT2hWw^qPuCvIqHrx~l^RBkTGOVRPdrJtWqALQ@NIc%480&!02R92!vNEyOfm^M&4BoAGdpxo3Y^Pg0R6h2MX2_MHhnwBYvh20c72D$iU-UC5-$3S%yJQri6@OTXcz7HYRqas1XQrE3dlm%7MZY!pKXJ$TmDFzTlKoHS8JXdP*oFyG1Lze3P_jHgidsQpypp^w^*hf~EGXsD0I0@$IzFj3-_wKp%xRCy^O8oKs5kPJ~cT99zBBkl^Qm&PGp&YtI3Y5rP7#Y8qdqeD~3f45QVBAo-S!m-BIBfNCrTr~UJy_POWT!$sW~DZrH41aDfTlyhNyk2HI0Ks%vYs2ixpat%mjXcpaOd2O&WS1@kPE!8#eJDU5o4%VQUz@%f4ivTqk~zY4zBtUO2XRp_~RbbpqS@^yCetTy4_X8&#t1H~f406Z^*Nha-PTXl8cgXXxWpvkCf8V9$AiG%igkk2~WYF~$VLduZVGB-6F4eEjmuWc8JJnIx$k^dA-BFdzvhpc-FI26Cdr7ljwUZ*zhdNRwHqIlBLdWXYbocGAZxaFR#TeKi^1cB%1S*ayqup&6hKu_mBSqyWb7rLSPI3GQJWjVSr~UO*CH%vf@WKH~RBJBv_TRIFPz^yKA$DU^68*yoK4UusI3z9Ipq3zY7OSjSefJ4mMTzg~BKDEUhffP6nIFB%M!6^qfu5Wpp@coOGEcIuG#~tUf@VGQ6vPN8CY96ZHE6bnbJ@&!^Mm3Ouci3Yv*eA-MvqP9Mes$xGe-vjEc4^zUnM*iO$J5#Vdsj25*FyES#~AkVQszi9Lgd4OV2ztzSLdwLibs_TF@uQ!*KM7xS!&ty^6DCkUpSdtJP7bW!7mVPVYOTt^1~VI9d^&qetbkwkeIjNMZ4nzDl$4eIvDhE7vd@yNy7fgaLEfVuS1&_1P%LzuqRpr%t*oE*rCr1LQd*0Jpbh1f6-v48rHTe!tfcGFXGT9XQY^ZKH^qxEZ&uW38sgd-5R0KCjb2S*51-itFiWFM6ZyeQu$G2d3Q$j_~0fU8p8e-yMOzkofq!g&i-NPYgZu5#3DZLrb0~sXyY7sCEqaJ-IHfL!&hj~H5D4WYcHN%6rv$@Zl%3VuD5m!frKuZVEP~pL*&rZbtDD^YMp7q3Qo^WFzZRdT4QItNsHqe#rPTI7wJ0U8bj8YeqL5y&!pnKVnDUFmrP$3du8nZsT$M9YtVD^$pVdc##w^ksSYmSVd_Ff1$w&P4Bl3&t$4HZ682!oGc4Y&jVwkj$d%OlhotakyprCgCQebIp~$m&5k0Hu2lG~xf#t*n5~sGf@51_owF1c1PKunwMc%F%Qcjs01H1!mR*bEN-0O$UN3vznFkaMuoR5zF2#Ct~6aC*Xk*w9Tq&ngW_#airnTLt0WG8ReiMyA*s%*nD!7&u*4_CW3NbRoJVpIUCjh6&uV~1bxp4EjxmOZ_im~zwXvhCrjHVZ*paoaA6c686bgoUpz1$#uuEwxEQrRvjC@x^W$O%GE_P69--RR^ywwz01tTkeaHnp#zpY^McD-VzL78QLRr0Iy_770sa*i^e1f3x8$LsQo591foQ-!4lv~iVFE&@ee@8#oftHQsCxy7J&9g-9jZe!8xCTyM73p2LQnegsXlNcmgbLVY95WuE~y*sRLhVhR%VEheqEfdO^poi1Qhe0xK_1Q3L6B8p$@ew^9udr1eeVJ&edsOtUO*n2T!Fq9qsD3!sB@K8PxDj!$W~^JZpAm-5d2zt9ielNtuyv_V53OWWSypnr0z8hRuo$LKlpz~eMP~zLQ2sc!QxI-C_W-!&$&q#r6y8HvyLM0By0#*w&vfdRN_LsvmOXfpnRF3ipU@sWI5_-h9rP0mdRk#QIB6VeNOmRB&G31-1mUY-HYeMVCCuZovKf_FVi#z0dgKrVYnCHQc19VF@Fc%QYuQZegqVMwilt2cbYHtfg*11vttIC0Hr~Z_Z~#uNdkG#5#A^&&xMneHLH@MrU5H~v7Bh4qcvn@Gfw0wH&vWDD0r#BY@&4to5Bp4_rY0WL30!dXEIQU1zoz7k~psB-Ko8PADG4lY_gKXS%pDM!mR*9$$35rbS2Kgz2XoK_~_zzovOLa-V7eKN8B%4oxK278MI8MoES@_S$KHuJaMjmOGD2sXLMsmT2q1$C9MQh!qsilCaTSWzEURTOzf2LA&!Wr39k5Y11O!xjPcOjVUdf54I1vDS3cR0-*S$U~tLtBY_SwS#QcDGYIIGTODWUAuXwwzaFZkIcJ5~~&GEsx*fAwXqzTRNW~vlEj!VnUB5myRa9*mUOO^I871#J68E&dzp_HBeP-E@P9nzjC**j&wa6XCC8MvDBoXYDj~oqisy~RhpBDyU8PjV&Rdk4$uD-qD6J$jXK$rWgsqricjKATfBnhf5Td28UWQNF1IpFmu2GraHH5EnT$J#q&LH2xeiYLX6Hqw^W#*$@tk~fqOICUe7a_DraRdiUE$2TlXOmomz2QD_m0@WZ&zMrZg7!I38Eu5snvCcpa&&e8ZKJnnE*RY-_O#nIY#~4FI3-e5gn&@%jM7zW!eNx&&4g6U5v~jXpwgy^KS3UcgWyS5u0w#GC3cmPtQ9^QBgXMpHppwJ1zf0-mD@Qd8RPU^CXwG!&fBTCVS%zKa1-fDyG6*&s%tuqHz~ky2cC2S5H^Y#U9iY2COvvbDjWPo445bcSl6ku-R#yo7u3#Rx!Xl0^$nBcIEHW9NdP-_eqp%hHnFmAhLaR-P~Ox-nHd#DICdhSxuFi^LX*DtS4O2EJzgb6bi*6gq*LIJ4h^4FCpvI-RFUf779QsVZXNf%3QJO6aFK~9PX%%kkNKMP0Nc#Mf2^cu*46*ni!E*Okd8La%!JDpKs2$wx#0#kuFkEh#k1Cv5i*#6ww%KnH6oc4B$xa54Gr!!&kW$R_YVPajOjTuTQz2^Hg^MYE2NWEG4~AYs#JY4i1FJEL-kKXjg#zx0mdyX8KuBP^@#s!1#1-N*j_oB2TZEC*fTnA6pu&areSJpgOO~uQKAcyURV&$cp^SMe$*RGILWlth7S^It8Vyp3_xKq3yrbOKjfiyd$sQQFYv&4JJ1#kVCZ4Ihof9iZa@iG26eWkc!NrPwEjqO8*N1ZvriP%jGyX0hsSiT%xC1Vfy~3uyjDMFJiY*Vc9*gC-P^#Mcjm%2ohcvNhME6fx*4f9fThRijQb11J@MzqHpcXHYSibWJrEiBSouw$!98PoEfWfJs_xejA^TL7h%Yj$6GhLCvs30BvprqB18^OnH3J%w9IfY4HUR@6W_6EmEVNzGUy7@HeNFKIJy@6r*W6l!jslHoI*d8hpY4qpFQWWQFM09Ev6#rA7#VXfWiKFq!XfhSTYG~a~9Zjb6*4xbNIn9o&w9mM4zLouHO6a^1z*k7FyQ&EFguhgwLh7DC@6pAGVOw^bG#h4&aYqyzJX4XM%xPB8a3SdVZ-N-#d9doRj#bBAvUvydxcsDEeML8YRauL4q0p-JLT$n#t2oXT93Ge2b_52L67455~*bcH8aNG^7AqdGPO_adaGHgbP^3A8^5JGyiHf&PNQ^gbDNgvk1Z1JM8_e4jXGy~6Ja3PNIahqjbqreWUtb66GfLMr$HLwbfUAI@@boQsHQI9x4Z_Zal#^u-X-n_x-mjW~*LH-bs936MmqRvAuOcM~cXSNiZl4&8k9CN!gH_5hv*Ok6*ZQ28s2bq7*RE*zLw_TGJOBpKBqsDr!_YNHwRW8~K_XKitndhpo2P-hiMW9kftc2~WgJPyuoY&f6y*hjg-3aR&zA#eVlgE8#C4uDB7*oRx!o5$F@-AkAZeqENIW*QIu5LYSyhgOu&b30i7lERhtmJLRJgfQ90yiior7hIc_QYnyWNOX@Yd&2*wM6Tv3R%MfN^foK1QV4QOUWA_**rH6AxMAbrrB2$xgQPZl2X!x~7z4yOTsHm0uV~mwU3cOn@kxVNiaisdSAEYBl6JQ_DyXXpe%O!2ag#IxYOXWC*S#BURDR0JCHafpJ&4AWtpsxo4dGoaNZtp3J$Ch^m$~g#r2u$plnsU4ruc8HfF47V1mxllNnfyRqF3UX#~h_@mc$1DoVyPM9PH_7N9^Nz7n8bKrSrqpNyck@4Yv1I547vhzateMH!4W8%ugbqdN0&9A#nxwchqZN2qhYMEk#dD0WOug#yhvrc-f&c&4O7v8nmcvm6-3cOPl2P5PcDc6bQixVNpvp*4IALVWGaWk$rTRT8U2ZYWSkkL$BZD--&F%iIh5K0aROVIjCOhU@k0-@cXeYx&fMjw7Md#kBb6CYg#t9T@k3PQY7b7tJlse@qWlf~knivM3sx-II1T4r_VwF#_pJOYPYJqTav19hhg@pIIDxdMkZc3Ig0Fgr9B9VgpweaspyV5B5PfBb#p@8SGE$hP@g6TVjhh5!IIafDx$L0NzYOii$~ZRxxBfeS6uCnIq5vF!iiIgdVhkj-z-nXObVOa15P*S9$5yMKOUQK$H$zIENk30%m@n3svUY_NBnY!gtSCJ8E4IKGxlaFPdsxZ9Mhzzy%p!wz5nxr-zk7AaV@Mz#7rCRhTnsXCs5YFST7TWO~s_E1jVC^^%~G7xiN9UoH8@tCn#*dmbHXxiMuCZau_rFHehKN2Ke#!_V_khRSsxTdAK-!FcmPxa%pdjV~3U5H-nzR*CMzPBN#wxd03t9y%E~cx1~$5g!2JTv^Ahh$EraTDW&J92Oi~XKxKjuGEiPR^x8~sTo^yOqIy9&f#_m8cQrKQsSBaWSqN~!w#H5gipIx@QZqQJ_ALspwIlrhjaK%Y46iAgbze$J2x6M^HPuLJ^QztEalfY@uJz*o32WrJhX^A6A4i@SH!&XXxK7JIJE5NWaZOcPhzXxOFc!BsdAKJ32Yp!E1QQZLNeZNvAlOeN9Me!Fiq~-YnUUVjSYLn*J!DSDOO$t%cW#Rg4lzaGzK9ujasGv^pbaL%wyJmySxcyF1N^opMc%O!502A^eflQx5p~Oad*mQ9QbkSS0K60N4Hsfw&Qw7gpDKMpjW@Wz1ORA65ay_O9Msli&sQY9L#0$JTxD^Y~OX0axs&Wlf_xLTUy4t5%q0EG3Zj#q_NYV4wzh3^r!BK~XoClS~C18aYiHiPeY$-Y6YkF$lHz8sVp#so42T3YcP4tEI4-_FHVIwZ-g-x@p$YpCr6SwZU*A2J$2!$GHskFa3Z#Vh&m8RK1J_e96Q-Eq63vt7dGoZHKUT0Q!jq5Be_$UMK5Y$EDcaaGCXWaTH0EdZ-tj1nj_HhRTU$uH^hj_0GHaRE6yz9ftK%@@9@*Sn%*6z8G-yxjr8M1S5kwcK_HdZDzn-eMBJcG^h5qEGOo8DK8#H_7pUxvO_rUBoch0en15*NY!jHXM9wzLUVdqtmh!*L^KKR#8qrjJ%PxDgRB1BXyp$UDHofwmfMS!OzkfCt!bVfZ84d@nf#G&h7L!1S!@spMVXa^EuNv~f2H5Aisni6ZPJd-bb1*QHI_&Xxiy8X5ru74I2VeF*kSFE2j%!l7fDRNib4R_3q$sVHmlAjreB^4gp~kviNg5JB5Q5sAqA#JpsD#qom%QQr$FdR!CENmOojI&XaNd7rO76RL^dh3Eiq2CJiPc*3ACLYwh*KZMBbfN6VurMdOxTC#nfZPiTckaA8y68AszOd7YbPZpL7vPlx4rfs&mZjDcR88WV^DJUpHU71rp*Pw@SHbp$wG3K*m6nwnT8-ZO18qOY%nfL8&%E~2#EzYa$JEUhxLXN*aVbvcw920oO!VeTh2j1laX6v%XX8DLdRH_tHbpyh6xvHl76WseE#KB@P97o4&!uMkDdhK~raDnUOkmUG26#3@uZZV!jLcIHG^dASqH~mpThk&Y$PA6ZFuIa7S5YWrVCdDSb*kAo*BuyzEF&N&DD0F9B7z_P%npKtT7LQGdR4Dkl5MTo8Al37Qiv#VSfxuKop-GmTMz-ZMTPxf7-O8SSbUgyNkt8ZnBhx9oP3wNuqEB9cI-w%~3aZiUaVAvC#-v_N0u-V&FR6eaJMAV3PoZcTRK8CjMQiKhPDZ%XnY$3qR&C!OEK!JUebDgys80ZLZ5nWxRVgAXuK$f*I!#bYhXGzQ5kWLDa*j5%Fm_T8&Ux8n*msB^NhpPK@1avNLQKJa28PH%Xd39#*Y8v!s7IMAhFvkUFO3ly#2lmwNvIvFmRmF_@NVak6iMOmXs2kS$76BevXio&A8j@8tC&4UrYPOaHzn^XCAjct*_&VG-y^FqWp^5$s3A61gMkQ6!@-UqwESXe2utCOU~Ain%XdUQ^YWfcCgC^b#6Qs$IUn1gxIL72ckNbW5&yRRL9&%-GImO9wmrn8WrfVvZKiipmlfS0TlYTGbDwzJl7VFNn&4ytFjIt2S2v96q%Asey~dAkPD9nqt^UdlUCML#sNTNonjCjH$8BmcovVsT8Ag7vANkz-ww&SJY6~Hk5CexGxoIc!RoO^CV2rtBU~wS&PuKPX7iEs3Vepl*vuiST4u%y7ItR2o&y@%KZizVVdj7-o2U$peKyYZmM34S!lly#$bzSpzp%OC!tj0RTkR6UQq65u0#Mjo4VscxOPi0TCFeJnQhaM3tTIVB%@4Y6Xkb!9o6I1nuFpcWWbrO#0%B6Ov~GDHIosyv*q$Iy6ru6*fwj!ba8bCRgS%gCGTD%zdHS#Qp~zkfG*1wJiIi4u14i$Dd*si7!1VblHC5dJfRxMX_4&8f0&4WF3i1*orucNXS2WAQiKdL~3$U*FIm7Ky^XuIE^zpP$*OoGU2@q!yfHASsXTRQ2~^mQObTvNDJ*5TTMJB4R_YoMltN^x#-Fve85nG6a6-#zzGH@YADN1gU6zX8EglgznD2xRGv5gdxtIgNUWk89r~r!VBEA^MH2_N!~Q^p&hvWpsdkX$%mMQA9DZ*$uM5vzx89Pp^MjQalk*R-Bf!3uH4Nm#B@th~cuaVM&zOrsMuMgxt~v2Y#7oGhhoq7pKjJN@t@&5*V~rQCmV0DeY$mS1-1V$dtqXmVHMzHwct^eMODO^73B3NMJDJXGJ@%GQlEl~3f_P4l_$m401~w~wn4mPHJ%MKctx43vVlN2f@x3fFAPKBq0wSQ3MEp2~^#zUJjA%Msk-h3^CiGm%!^e5QklYqlwAVBtsWsI9sG%9jB30Ey$z-0H@13ngj!g$u6B66yx~Do7$v6g8N_fBIzZXh3$dvD9mt%brJso~AGiHtHgFS0JF*X&!9_vk5nJtOJaBQ-hHXiNAa9ooaBSX2EDOZP~3bIcUeVzk#Bi~9320M3_^4nRIPp45c*7Aqy@Jjws*!WGv8ha&Hw!tyJWmCWlH~DIg@HrJZnEqNnD%2Vu%!4mDULqpFBSotghLFqyZiLol7GmIs7qzj1jjakhgF^$MS%ia-FVp&~UJv9h_XhAIfsNslM_P4OVWqn9^o6VJdRZL@MYq~*cJovrVWPW0k0b4aCgWrGIT5Rn$ogfs*%OUi3&Ful_Rn#gh-U85ynsEe5OAVcVCiqWgL#SFuyR$xw&kl@yLagO-Ri2mp$~uG@mcLQ~wmQ2c5daujWV229Cyi-6Rq_&qL##FITesVeh18OqOHsDW!BHuclR%e0x2-%LYu8u8H8U833^jI2CKn*NJSAthYHffK@t&fEd~OQ&FSEgD9sGuf-#bkPJYqBXdGWR1vRX3Yhwssn_%qd~khls6ff#j@FDjtX2cBvd-UP8OcI52DG3~ZJ*a53HBT2AETacG5fODRpCEd#o5e_%10Yd^pu%gt&$9DG!k8SZSds8I*R!xr
diff --git a/internal/media/test/test-jpeg-blurhash.jpg b/internal/media/test/test-jpeg-blurhash.jpg
deleted file mode 100644
index 6b6ba472e..000000000
--- a/internal/media/test/test-jpeg-blurhash.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/test/test-with-exif.jpg b/internal/media/test/test-with-exif.jpg
deleted file mode 100644
index de56cd654..000000000
--- a/internal/media/test/test-with-exif.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/test/test-without-exif.jpg b/internal/media/test/test-without-exif.jpg
deleted file mode 100644
index 274188ee7..000000000
--- a/internal/media/test/test-without-exif.jpg
+++ /dev/null
Binary files differ
diff --git a/internal/media/types.go b/internal/media/types.go
new file mode 100644
index 000000000..b9c79d464
--- /dev/null
+++ b/internal/media/types.go
@@ -0,0 +1,121 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "context"
+ "io"
+ "time"
+)
+
+// maxFileHeaderBytes represents the maximum amount of bytes we want
+// to examine from the beginning of a file to determine its type.
+//
+// See: https://en.wikipedia.org/wiki/File_format#File_header
+// and https://github.com/h2non/filetype
+const maxFileHeaderBytes = 261
+
+// mime consts
+const (
+ mimeImage = "image"
+
+ mimeJpeg = "jpeg"
+ mimeImageJpeg = mimeImage + "/" + mimeJpeg
+
+ mimeGif = "gif"
+ mimeImageGif = mimeImage + "/" + mimeGif
+
+ mimePng = "png"
+ mimeImagePng = mimeImage + "/" + mimePng
+)
+
+type processState int
+
+const (
+ received processState = iota // processing order has been received but not done yet
+ complete // processing order has been completed successfully
+ errored // processing order has been completed with an error
+)
+
+// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
+// const EmojiMaxBytes = 51200
+
+type Size string
+
+const (
+ SizeSmall Size = "small" // SizeSmall is the key for small/thumbnail versions of media
+ SizeOriginal Size = "original" // SizeOriginal is the key for original/fullsize versions of media and emoji
+ SizeStatic Size = "static" // SizeStatic is the key for static (non-animated) versions of emoji
+)
+
+type Type string
+
+const (
+ TypeAttachment Type = "attachment" // TypeAttachment is the key for media attachments
+ TypeHeader Type = "header" // TypeHeader is the key for profile header requests
+ TypeAvatar Type = "avatar" // TypeAvatar is the key for profile avatar requests
+ TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests
+)
+
+// AdditionalMediaInfo represents additional information that should be added to an attachment
+// when processing a piece of media.
+type AdditionalMediaInfo struct {
+ // Time that this media was created; defaults to time.Now().
+ CreatedAt *time.Time
+ // ID of the status to which this media is attached; defaults to "".
+ StatusID *string
+ // URL of the media on a remote instance; defaults to "".
+ RemoteURL *string
+ // Image description of this media; defaults to "".
+ Description *string
+ // Blurhash of this media; defaults to "".
+ Blurhash *string
+ // ID of the scheduled status to which this media is attached; defaults to "".
+ ScheduledStatusID *string
+ // Mark this media as in-use as an avatar; defaults to false.
+ Avatar *bool
+ // Mark this media as in-use as a header; defaults to false.
+ Header *bool
+ // X focus coordinate for this media; defaults to 0.
+ FocusX *float32
+ // Y focus coordinate for this media; defaults to 0.
+ FocusY *float32
+}
+
+// AdditionalMediaInfo represents additional information
+// that should be added to an emoji when processing it.
+type AdditionalEmojiInfo struct {
+ // Time that this emoji was created; defaults to time.Now().
+ CreatedAt *time.Time
+ // Domain the emoji originated from. Blank for this instance's domain. Defaults to "".
+ Domain *string
+ // URL of this emoji on a remote instance; defaults to "".
+ ImageRemoteURL *string
+ // URL of the static version of this emoji on a remote instance; defaults to "".
+ ImageStaticRemoteURL *string
+ // Whether this emoji should be disabled (not shown) on this instance; defaults to false.
+ Disabled *bool
+ // Whether this emoji should be visible in the instance's emoji picker; defaults to true.
+ VisibleInPicker *bool
+ // ID of the category this emoji should be placed in; defaults to "".
+ CategoryID *string
+}
+
+// DataFunc represents a function used to retrieve the raw bytes of a piece of media.
+type DataFunc func(ctx context.Context) (reader io.Reader, fileSize int, err error)
diff --git a/internal/media/util.go b/internal/media/util.go
index 348136c92..248d5fb19 100644
--- a/internal/media/util.go
+++ b/internal/media/util.go
@@ -19,50 +19,22 @@
package media
import (
- "bytes"
"errors"
"fmt"
- "image"
- "image/gif"
- "image/jpeg"
- "image/png"
- "github.com/buckket/go-blurhash"
"github.com/h2non/filetype"
- "github.com/nfnt/resize"
- "github.com/superseriousbusiness/exifremove/pkg/exifremove"
-)
-
-const (
- // MIMEImage is the mime type for image
- MIMEImage = "image"
- // MIMEJpeg is the jpeg image mime type
- MIMEJpeg = "image/jpeg"
- // MIMEGif is the gif image mime type
- MIMEGif = "image/gif"
- // MIMEPng is the png image mime type
- MIMEPng = "image/png"
-
- // MIMEVideo is the mime type for video
- MIMEVideo = "video"
- // MIMEMp4 is the mp4 video mime type
- MIMEMp4 = "video/mp4"
- // MIMEMpeg is the mpeg video mime type
- MIMEMpeg = "video/mpeg"
- // MIMEWebm is the webm video mime type
- MIMEWebm = "video/webm"
)
// parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg").
// Returns an error if the content type is not something we can process.
-func parseContentType(content []byte) (string, error) {
- head := make([]byte, 261)
- _, err := bytes.NewReader(content).Read(head)
- if err != nil {
- return "", fmt.Errorf("could not read first magic bytes of file: %s", err)
+//
+// Fileheader should be no longer than 262 bytes; anything more than this is inefficient.
+func parseContentType(fileHeader []byte) (string, error) {
+ if fhLength := len(fileHeader); fhLength > maxFileHeaderBytes {
+ return "", fmt.Errorf("parseContentType requires %d bytes max, we got %d", maxFileHeaderBytes, fhLength)
}
- kind, err := filetype.Match(head)
+ kind, err := filetype.Match(fileHeader)
if err != nil {
return "", err
}
@@ -74,13 +46,13 @@ func parseContentType(content []byte) (string, error) {
return kind.MIME.Value, nil
}
-// SupportedImageType checks mime type of an image against a slice of accepted types,
+// supportedImage checks mime type of an image against a slice of accepted types,
// and returns True if the mime type is accepted.
-func SupportedImageType(mimeType string) bool {
+func supportedImage(mimeType string) bool {
acceptedImageTypes := []string{
- MIMEJpeg,
- MIMEGif,
- MIMEPng,
+ mimeImageJpeg,
+ mimeImageGif,
+ mimeImagePng,
}
for _, accepted := range acceptedImageTypes {
if mimeType == accepted {
@@ -90,27 +62,11 @@ func SupportedImageType(mimeType string) bool {
return false
}
-// SupportedVideoType checks mime type of a video against a slice of accepted types,
-// and returns True if the mime type is accepted.
-func SupportedVideoType(mimeType string) bool {
- acceptedVideoTypes := []string{
- MIMEMp4,
- MIMEMpeg,
- MIMEWebm,
- }
- for _, accepted := range acceptedVideoTypes {
- if mimeType == accepted {
- return true
- }
- }
- return false
-}
-
-// supportedEmojiType checks that the content type is image/png -- the only type supported for emoji.
-func supportedEmojiType(mimeType string) bool {
+// supportedEmoji checks that the content type is image/png or image/gif -- the only types supported for emoji.
+func supportedEmoji(mimeType string) bool {
acceptedEmojiTypes := []string{
- MIMEGif,
- MIMEPng,
+ mimeImageGif,
+ mimeImagePng,
}
for _, accepted := range acceptedEmojiTypes {
if mimeType == accepted {
@@ -120,179 +76,6 @@ func supportedEmojiType(mimeType string) bool {
return false
}
-// purgeExif is a little wrapper for the action of removing exif data from an image.
-// Only pass pngs or jpegs to this function.
-func purgeExif(b []byte) ([]byte, error) {
- if len(b) == 0 {
- return nil, errors.New("passed image was not valid")
- }
-
- clean, err := exifremove.Remove(b)
- if err != nil {
- return nil, fmt.Errorf("could not purge exif from image: %s", err)
- }
- if len(clean) == 0 {
- return nil, errors.New("purged image was not valid")
- }
- return clean, nil
-}
-
-func deriveGif(b []byte, extension string) (*imageAndMeta, error) {
- var g *gif.GIF
- var err error
- switch extension {
- case MIMEGif:
- g, err = gif.DecodeAll(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- default:
- return nil, fmt.Errorf("extension %s not recognised", extension)
- }
-
- // use the first frame to get the static characteristics
- width := g.Config.Width
- height := g.Config.Height
- size := width * height
- aspect := float64(width) / float64(height)
-
- return &imageAndMeta{
- image: b,
- width: width,
- height: height,
- size: size,
- aspect: aspect,
- }, nil
-}
-
-func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {
- var i image.Image
- var err error
-
- switch contentType {
- case MIMEJpeg:
- i, err = jpeg.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- case MIMEPng:
- i, err = png.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- default:
- return nil, fmt.Errorf("content type %s not recognised", contentType)
- }
-
- width := i.Bounds().Size().X
- height := i.Bounds().Size().Y
- size := width * height
- aspect := float64(width) / float64(height)
-
- return &imageAndMeta{
- image: b,
- width: width,
- height: height,
- size: size,
- aspect: aspect,
- }, nil
-}
-
-// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y,
-// of a given jpeg, png, or gif, or an error if something goes wrong.
-//
-// Note that the aspect ratio of the image will be retained,
-// so it will not necessarily be a square, even if x and y are set as the same value.
-func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) {
- var i image.Image
- var err error
-
- switch contentType {
- case MIMEJpeg:
- i, err = jpeg.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- case MIMEPng:
- i, err = png.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- case MIMEGif:
- i, err = gif.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- default:
- return nil, fmt.Errorf("content type %s not recognised", contentType)
- }
-
- thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor)
- width := thumb.Bounds().Size().X
- height := thumb.Bounds().Size().Y
- size := width * height
- aspect := float64(width) / float64(height)
-
- tiny := resize.Thumbnail(32, 32, thumb, resize.NearestNeighbor)
- bh, err := blurhash.Encode(4, 3, tiny)
- if err != nil {
- return nil, err
- }
-
- out := &bytes.Buffer{}
- if err := jpeg.Encode(out, thumb, &jpeg.Options{
- Quality: 75,
- }); err != nil {
- return nil, err
- }
- return &imageAndMeta{
- image: out.Bytes(),
- width: width,
- height: height,
- size: size,
- aspect: aspect,
- blurhash: bh,
- }, nil
-}
-
-// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png.
-func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) {
- var i image.Image
- var err error
-
- switch contentType {
- case MIMEPng:
- i, err = png.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- case MIMEGif:
- i, err = gif.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- default:
- return nil, fmt.Errorf("content type %s not allowed for emoji", contentType)
- }
-
- out := &bytes.Buffer{}
- if err := png.Encode(out, i); err != nil {
- return nil, err
- }
- return &imageAndMeta{
- image: out.Bytes(),
- }, nil
-}
-
-type imageAndMeta struct {
- image []byte
- width int
- height int
- size int
- aspect float64
- blurhash string
-}
-
// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized
func ParseMediaType(s string) (Type, error) {
switch s {
diff --git a/internal/media/util_test.go b/internal/media/util_test.go
deleted file mode 100644
index cb299d50e..000000000
--- a/internal/media/util_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "io/ioutil"
- "testing"
-
- "github.com/spf13/viper"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/log"
-
- "github.com/stretchr/testify/suite"
-)
-
-type MediaUtilTestSuite struct {
- suite.Suite
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *MediaUtilTestSuite) SetupSuite() {
- // doesn't use testrig.InitTestLog() helper to prevent import cycle
- viper.Set(config.Keys.LogLevel, "trace")
- err := log.Initialize()
- if err != nil {
- panic(err)
- }
-}
-
-func (suite *MediaUtilTestSuite) TearDownSuite() {
-
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *MediaUtilTestSuite) SetupTest() {
-
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *MediaUtilTestSuite) TearDownTest() {
-
-}
-
-/*
- ACTUAL TESTS
-*/
-
-func (suite *MediaUtilTestSuite) TestParseContentTypeOK() {
- f, err := ioutil.ReadFile("./test/test-jpeg.jpg")
- suite.NoError(err)
- ct, err := parseContentType(f)
- suite.NoError(err)
- suite.Equal("image/jpeg", ct)
-}
-
-func (suite *MediaUtilTestSuite) TestParseContentTypeNotOK() {
- f, err := ioutil.ReadFile("./test/test-corrupted.jpg")
- suite.NoError(err)
- ct, err := parseContentType(f)
- suite.NotNil(err)
- suite.Equal("", ct)
- suite.Equal("filetype unknown", err.Error())
-}
-
-func (suite *MediaUtilTestSuite) TestRemoveEXIF() {
- // load and validate image
- b, err := ioutil.ReadFile("./test/test-with-exif.jpg")
- suite.NoError(err)
-
- // clean it up and validate the clean version
- clean, err := purgeExif(b)
- suite.NoError(err)
-
- // compare it to our stored sample
- sampleBytes, err := ioutil.ReadFile("./test/test-without-exif.jpg")
- suite.NoError(err)
- suite.EqualValues(sampleBytes, clean)
-}
-
-func (suite *MediaUtilTestSuite) TestDeriveImageFromJPEG() {
- // load image
- b, err := ioutil.ReadFile("./test/test-jpeg.jpg")
- suite.NoError(err)
-
- // clean it up and validate the clean version
- imageAndMeta, err := deriveImage(b, "image/jpeg")
- suite.NoError(err)
-
- suite.Equal(1920, imageAndMeta.width)
- suite.Equal(1080, imageAndMeta.height)
- suite.Equal(1.7777777777777777, imageAndMeta.aspect)
- suite.Equal(2073600, imageAndMeta.size)
-
- // assert that the final image is what we would expect
- sampleBytes, err := ioutil.ReadFile("./test/test-jpeg-processed.jpg")
- suite.NoError(err)
- suite.EqualValues(sampleBytes, imageAndMeta.image)
-}
-
-func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() {
- // load image
- b, err := ioutil.ReadFile("./test/test-jpeg.jpg")
- suite.NoError(err)
-
- // clean it up and validate the clean version
- imageAndMeta, err := deriveThumbnail(b, "image/jpeg", 512, 512)
- suite.NoError(err)
-
- suite.Equal(512, imageAndMeta.width)
- suite.Equal(288, imageAndMeta.height)
- suite.Equal(1.7777777777777777, imageAndMeta.aspect)
- suite.Equal(147456, imageAndMeta.size)
- suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", imageAndMeta.blurhash)
-
- sampleBytes, err := ioutil.ReadFile("./test/test-jpeg-thumbnail.jpg")
- suite.NoError(err)
- suite.EqualValues(sampleBytes, imageAndMeta.image)
-}
-
-func (suite *MediaUtilTestSuite) TestSupportedImageTypes() {
- ok := SupportedImageType("image/jpeg")
- suite.True(ok)
-
- ok = SupportedImageType("image/bmp")
- suite.False(ok)
-}
-
-func TestMediaUtilTestSuite(t *testing.T) {
- suite.Run(t, new(MediaUtilTestSuite))
-}