summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>2024-07-12 09:39:47 +0000
committerLibravatar GitHub <noreply@github.com>2024-07-12 09:39:47 +0000
commitcde2fb6244a791b3c5b746112e3a8be3a79f39a4 (patch)
tree6079d6fb66d90ffbe8c1623525bb86829c162459 /internal
parent[chore] Add interaction policy gtsmodels (#3075) (diff)
downloadgotosocial-cde2fb6244a791b3c5b746112e3a8be3a79f39a4.tar.xz
[feature] support processing of (many) more media types (#3090)
* initial work replacing our media decoding / encoding pipeline with ffprobe + ffmpeg * specify the video codec to use when generating static image from emoji * update go-storage library (fixes incompatibility after updating go-iotools) * maintain image aspect ratio when generating a thumbnail for it * update readme to show go-ffmpreg * fix a bunch of media tests, move filesize checking to callers of media manager for more flexibility * remove extra debug from error message * fix up incorrect function signatures * update PutFile to just use regular file copy, as changes are file is on separate partition * fix remaining tests, remove some unneeded tests now we're working with ffmpeg/ffprobe * update more tests, add more code comments * add utilities to generate processed emoji / media outputs * fix remaining tests * add test for opus media file, add license header to utility cmds * limit the number of concurrently available ffmpeg / ffprobe instances * reduce number of instances * further reduce number of instances * fix envparsing test with configuration variables * update docs and configuration with new media-{local,remote}-max-size variables
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/admin/emojicreate_test.go12
-rw-r--r--internal/api/client/admin/emojidelete_test.go2
-rw-r--r--internal/api/client/admin/emojiget_test.go4
-rw-r--r--internal/api/client/admin/emojiupdate_test.go30
-rw-r--r--internal/api/client/instance/instancepatch.go7
-rw-r--r--internal/api/client/instance/instancepatch_test.go14
-rw-r--r--internal/api/client/media/mediacreate.go13
-rw-r--r--internal/api/client/media/mediacreate_test.go4
-rw-r--r--internal/cleaner/media_test.go4
-rw-r--r--internal/config/config.go4
-rw-r--r--internal/config/defaults.go4
-rw-r--r--internal/config/flags.go4
-rw-r--r--internal/config/helpers.gen.go100
-rw-r--r--internal/federation/dereferencing/emoji.go15
-rw-r--r--internal/federation/dereferencing/emoji_test.go2
-rw-r--r--internal/federation/dereferencing/media.go15
-rw-r--r--internal/httpclient/client.go37
-rw-r--r--internal/httpclient/client_test.go39
-rw-r--r--internal/media/ffmpeg.go313
-rw-r--r--internal/media/ffmpeg/cache.go46
-rw-r--r--internal/media/ffmpeg/ffmpeg.go92
-rw-r--r--internal/media/ffmpeg/ffprobe.go92
-rw-r--r--internal/media/ffmpeg/pool.go75
-rw-r--r--internal/media/image.go189
-rw-r--r--internal/media/manager.go30
-rw-r--r--internal/media/manager_test.go809
-rw-r--r--internal/media/png-stripper.go211
-rw-r--r--internal/media/processingemoji.go210
-rw-r--r--internal/media/processingmedia.go437
-rw-r--r--internal/media/refetch.go10
-rw-r--r--internal/media/test/birdnest-processed.mp4bin1409577 -> 1409625 bytes
-rw-r--r--internal/media/test/birdnest-thumbnail.jpgbin2897 -> 10114 bytes
-rw-r--r--internal/media/test/gts_pixellated-static.pngbin1010 -> 1512 bytes
-rw-r--r--internal/media/test/longer-mp4-processed.mp4bin109549 -> 109569 bytes
-rw-r--r--internal/media/test/longer-mp4-thumbnail.jpgbin2897 -> 3789 bytes
-rw-r--r--internal/media/test/nb-flag-static.pngbin878 -> 709 bytes
-rw-r--r--internal/media/test/rainbow-static.pngbin10413 -> 6092 bytes
-rw-r--r--internal/media/test/test-jpeg-thumbnail.jpgbin20973 -> 11113 bytes
-rw-r--r--internal/media/test/test-mp4-processed.mp4bin312413 -> 312453 bytes
-rw-r--r--internal/media/test/test-mp4-thumbnail.jpgbin1913 -> 4543 bytes
-rw-r--r--internal/media/test/test-opus-original.opusbin0 -> 1776956 bytes
-rw-r--r--internal/media/test/test-opus-processed.opusbin0 -> 1776956 bytes
-rw-r--r--internal/media/test/test-png-alphachannel-processed.pngbin18904 -> 18904 bytes
-rw-r--r--internal/media/test/test-png-alphachannel-thumbnail.jpgbin5984 -> 8029 bytes
-rw-r--r--internal/media/test/test-png-noalphachannel-thumbnail.jpgbin5984 -> 8063 bytes
-rw-r--r--internal/media/types.go2
-rw-r--r--internal/media/util.go176
-rw-r--r--internal/media/video.go141
-rw-r--r--internal/processing/account/update.go50
-rw-r--r--internal/processing/admin/emoji.go64
-rw-r--r--internal/processing/admin/media.go4
-rw-r--r--internal/processing/media/create.go33
-rw-r--r--internal/processing/media/getfile_test.go27
-rw-r--r--internal/storage/storage.go25
-rw-r--r--internal/transport/derefmedia.go22
-rw-r--r--internal/transport/transport.go4
-rw-r--r--internal/typeutils/internaltofrontend.go8
-rw-r--r--internal/typeutils/internaltofrontend_test.go8
58 files changed, 1505 insertions, 1883 deletions
diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go
index be39ebdf5..a687fb0af 100644
--- a/internal/api/client/admin/emojicreate_test.go
+++ b/internal/api/client/admin/emojicreate_test.go
@@ -90,10 +90,10 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateNewCategory() {
suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL)
suite.NotEmpty(dbEmoji.ImagePath)
suite.NotEmpty(dbEmoji.ImageStaticPath)
- suite.Equal("image/png", dbEmoji.ImageContentType)
+ suite.Equal("image/apng", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(36702, dbEmoji.ImageFileSize)
- suite.Equal(10413, dbEmoji.ImageStaticFileSize)
+ suite.Equal(6092, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
@@ -163,10 +163,10 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateExistingCategory() {
suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL)
suite.NotEmpty(dbEmoji.ImagePath)
suite.NotEmpty(dbEmoji.ImageStaticPath)
- suite.Equal("image/png", dbEmoji.ImageContentType)
+ suite.Equal("image/apng", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(36702, dbEmoji.ImageFileSize)
- suite.Equal(10413, dbEmoji.ImageStaticFileSize)
+ suite.Equal(6092, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
@@ -236,10 +236,10 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateNoCategory() {
suite.Equal(apiEmoji.StaticURL, dbEmoji.ImageStaticURL)
suite.NotEmpty(dbEmoji.ImagePath)
suite.NotEmpty(dbEmoji.ImageStaticPath)
- suite.Equal("image/png", dbEmoji.ImageContentType)
+ suite.Equal("image/apng", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(36702, dbEmoji.ImageFileSize)
- suite.Equal(10413, dbEmoji.ImageStaticFileSize)
+ suite.Equal(6092, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
diff --git a/internal/api/client/admin/emojidelete_test.go b/internal/api/client/admin/emojidelete_test.go
index 10cf3fe8d..88e929b55 100644
--- a/internal/api/client/admin/emojidelete_test.go
+++ b/internal/api/client/admin/emojidelete_test.go
@@ -62,7 +62,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() {
"id": "01F8MH9H8E4VG3KDYJR9EGPXCQ",
"disabled": false,
"updated_at": "2021-09-20T10:40:37.000Z",
- "total_file_size": 47115,
+ "total_file_size": 42794,
"content_type": "image/png",
"uri": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"
}`, dst.String())
diff --git a/internal/api/client/admin/emojiget_test.go b/internal/api/client/admin/emojiget_test.go
index b8bad2536..d6b2924ab 100644
--- a/internal/api/client/admin/emojiget_test.go
+++ b/internal/api/client/admin/emojiget_test.go
@@ -60,7 +60,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() {
"id": "01F8MH9H8E4VG3KDYJR9EGPXCQ",
"disabled": false,
"updated_at": "2021-09-20T10:40:37.000Z",
- "total_file_size": 47115,
+ "total_file_size": 42794,
"content_type": "image/png",
"uri": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"
}`, dst.String())
@@ -92,7 +92,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet2() {
"disabled": false,
"domain": "fossbros-anonymous.io",
"updated_at": "2020-03-18T12:12:00.000Z",
- "total_file_size": 21697,
+ "total_file_size": 19854,
"content_type": "image/png",
"uri": "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW"
}`, dst.String())
diff --git a/internal/api/client/admin/emojiupdate_test.go b/internal/api/client/admin/emojiupdate_test.go
index 11beaeaa9..073e3cec0 100644
--- a/internal/api/client/admin/emojiupdate_test.go
+++ b/internal/api/client/admin/emojiupdate_test.go
@@ -100,19 +100,19 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
suite.Equal("image/png", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(36702, dbEmoji.ImageFileSize)
- suite.Equal(10413, dbEmoji.ImageStaticFileSize)
+ suite.Equal(6092, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
suite.NotEmpty(dbEmoji.CategoryID)
// emoji should be in storage
- emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath)
+ entry, err := suite.storage.Storage.Stat(ctx, dbEmoji.ImagePath)
suite.NoError(err)
- suite.Len(emojiBytes, dbEmoji.ImageFileSize)
- emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath)
+ suite.Equal(int64(dbEmoji.ImageFileSize), entry.Size)
+ entry, err = suite.storage.Storage.Stat(ctx, dbEmoji.ImageStaticPath)
suite.NoError(err)
- suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize)
+ suite.Equal(int64(dbEmoji.ImageStaticFileSize), entry.Size)
}
func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
@@ -177,19 +177,19 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
suite.Equal("image/png", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(36702, dbEmoji.ImageFileSize)
- suite.Equal(10413, dbEmoji.ImageStaticFileSize)
+ suite.Equal(6092, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
suite.NotEmpty(dbEmoji.CategoryID)
// emoji should be in storage
- emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath)
+ entry, err := suite.storage.Storage.Stat(ctx, dbEmoji.ImagePath)
suite.NoError(err)
- suite.Len(emojiBytes, dbEmoji.ImageFileSize)
- emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath)
+ suite.Equal(int64(dbEmoji.ImageFileSize), entry.Size)
+ entry, err = suite.storage.Storage.Stat(ctx, dbEmoji.ImageStaticPath)
suite.NoError(err)
- suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize)
+ suite.Equal(int64(dbEmoji.ImageStaticFileSize), entry.Size)
}
func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
@@ -255,19 +255,19 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
suite.Equal("image/png", dbEmoji.ImageContentType)
suite.Equal("image/png", dbEmoji.ImageStaticContentType)
suite.Equal(10889, dbEmoji.ImageFileSize)
- suite.Equal(10672, dbEmoji.ImageStaticFileSize)
+ suite.Equal(8965, dbEmoji.ImageStaticFileSize)
suite.False(*dbEmoji.Disabled)
suite.NotEmpty(dbEmoji.URI)
suite.True(*dbEmoji.VisibleInPicker)
suite.NotEmpty(dbEmoji.CategoryID)
// emoji should be in storage
- emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath)
+ entry, err := suite.storage.Storage.Stat(ctx, dbEmoji.ImagePath)
suite.NoError(err)
- suite.Len(emojiBytes, dbEmoji.ImageFileSize)
- emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath)
+ suite.Equal(int64(dbEmoji.ImageFileSize), entry.Size)
+ entry, err = suite.storage.Storage.Stat(ctx, dbEmoji.ImageStaticPath)
suite.NoError(err)
- suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize)
+ suite.Equal(int64(dbEmoji.ImageStaticFileSize), entry.Size)
}
func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go
index afddc5a50..64263caf6 100644
--- a/internal/api/client/instance/instancepatch.go
+++ b/internal/api/client/instance/instancepatch.go
@@ -182,13 +182,6 @@ func validateInstanceUpdate(form *apimodel.InstanceSettingsUpdateRequest) error
return errors.New("empty form submitted")
}
- if form.Avatar != nil {
- maxImageSize := config.GetMediaImageMaxSize()
- if size := form.Avatar.Size; size > int64(maxImageSize) {
- return fmt.Errorf("file size limit exceeded: limit is %d bytes but desired instance avatar was %d bytes", maxImageSize, size)
- }
- }
-
if form.AvatarDescription != nil {
maxDescriptionChars := config.GetMediaDescriptionMaxChars()
if length := len([]rune(*form.AvatarDescription)); length > maxDescriptionChars {
diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go
index 936d6efd9..605b056b9 100644
--- a/internal/api/client/instance/instancepatch_test.go
+++ b/internal/api/client/instance/instancepatch_test.go
@@ -109,7 +109,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -230,7 +230,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -351,7 +351,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -523,7 +523,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -666,7 +666,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -754,7 +754,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/original/`+instanceAccount.AvatarMediaAttachment.ID+`.gif",`+`
"thumbnail_type": "image/gif",
"thumbnail_description": "A bouncing little green peglin.",
- "blurhash": "LG9t;qRS4YtO.4WDRlt5IXoxtPj["
+ "blurhash": "LtJ[eKxu_4xt9Yj]M{WBt8WBM{WB"
}`, string(instanceV2ThumbnailJson))
// double extra special bonus: now update the image description without changing the image
@@ -824,7 +824,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go
index eef945d21..efe567f13 100644
--- a/internal/api/client/media/mediacreate.go
+++ b/internal/api/client/media/mediacreate.go
@@ -153,22 +153,9 @@ func validateCreateMedia(form *apimodel.AttachmentRequest) error {
return errors.New("no attachment given")
}
- maxVideoSize := config.GetMediaVideoMaxSize()
- maxImageSize := config.GetMediaImageMaxSize()
minDescriptionChars := config.GetMediaDescriptionMinChars()
maxDescriptionChars := config.GetMediaDescriptionMaxChars()
- // a very superficial check to see if no size limits are exceeded
- // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
- maxSize := maxVideoSize
- if maxImageSize > maxSize {
- maxSize = maxImageSize
- }
-
- if form.File.Size > int64(maxSize) {
- return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
- }
-
if length := len([]rune(form.Description)); length > maxDescriptionChars {
return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", minDescriptionChars, maxDescriptionChars, length)
}
diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
index c2871aff0..2f6813a7c 100644
--- a/internal/api/client/media/mediacreate_test.go
+++ b/internal/api/client/media/mediacreate_test.go
@@ -206,7 +206,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() {
Y: 0.5,
},
}, *attachmentReply.Meta)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", *attachmentReply.Blurhash)
+ suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", *attachmentReply.Blurhash)
suite.NotEmpty(attachmentReply.ID)
suite.NotEmpty(attachmentReply.URL)
suite.NotEmpty(attachmentReply.PreviewURL)
@@ -291,7 +291,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() {
Y: 0.5,
},
}, *attachmentReply.Meta)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", *attachmentReply.Blurhash)
+ suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", *attachmentReply.Blurhash)
suite.NotEmpty(attachmentReply.ID)
suite.Nil(attachmentReply.URL)
suite.NotEmpty(attachmentReply.PreviewURL)
diff --git a/internal/cleaner/media_test.go b/internal/cleaner/media_test.go
index acb5416f7..46c6edcd4 100644
--- a/internal/cleaner/media_test.go
+++ b/internal/cleaner/media_test.go
@@ -373,13 +373,13 @@ func (suite *MediaTestSuite) TestUncacheAndRecache() {
suite.True(storage.IsNotFound(err))
// now recache the image....
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("../../testrig/media/thoughtsofdog-original.jpg")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
for _, original := range []*gtsmodel.MediaAttachment{
diff --git a/internal/config/config.go b/internal/config/config.go
index 015213184..bffa5b455 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -92,13 +92,13 @@ type Configuration struct {
AccountsAllowCustomCSS bool `name:"accounts-allow-custom-css" usage:"Allow accounts to enable custom CSS for their profile pages and statuses."`
AccountsCustomCSSLength int `name:"accounts-custom-css-length" usage:"Maximum permitted length (characters) of custom CSS for accounts."`
- MediaImageMaxSize bytesize.Size `name:"media-image-max-size" usage:"Max size of accepted images in bytes"`
- MediaVideoMaxSize bytesize.Size `name:"media-video-max-size" usage:"Max size of accepted videos in bytes"`
MediaDescriptionMinChars int `name:"media-description-min-chars" usage:"Min required chars for an image description"`
MediaDescriptionMaxChars int `name:"media-description-max-chars" usage:"Max permitted chars for an image description"`
MediaRemoteCacheDays int `name:"media-remote-cache-days" usage:"Number of days to locally cache media from remote instances. If set to 0, remote media will be kept indefinitely."`
MediaEmojiLocalMaxSize bytesize.Size `name:"media-emoji-local-max-size" usage:"Max size in bytes of emojis uploaded to this instance via the admin API."`
MediaEmojiRemoteMaxSize bytesize.Size `name:"media-emoji-remote-max-size" usage:"Max size in bytes of emojis to download from other instances."`
+ MediaLocalMaxSize bytesize.Size `name:"media-local-max-size" usage:"Max size in bytes of media uploaded to this instance via API"`
+ MediaRemoteMaxSize bytesize.Size `name:"media-remote-max-size" usage:"Max size in bytes of media to download from other instances"`
MediaCleanupFrom string `name:"media-cleanup-from" usage:"Time of day from which to start running media cleanup/prune jobs. Should be in the format 'hh:mm:ss', eg., '15:04:05'."`
MediaCleanupEvery time.Duration `name:"media-cleanup-every" usage:"Period to elapse between cleanups, starting from media-cleanup-at."`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index ba068761e..267e7b4bc 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -71,11 +71,11 @@ var Defaults = Configuration{
AccountsAllowCustomCSS: false,
AccountsCustomCSSLength: 10000,
- MediaImageMaxSize: 10 * bytesize.MiB,
- MediaVideoMaxSize: 40 * bytesize.MiB,
MediaDescriptionMinChars: 0,
MediaDescriptionMaxChars: 1500,
MediaRemoteCacheDays: 7,
+ MediaLocalMaxSize: 40 * bytesize.MiB,
+ MediaRemoteMaxSize: 40 * bytesize.MiB,
MediaEmojiLocalMaxSize: 50 * bytesize.KiB,
MediaEmojiRemoteMaxSize: 100 * bytesize.KiB,
MediaCleanupFrom: "00:00", // Midnight.
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 042621afe..f96709e70 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -97,11 +97,11 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().Bool(AccountsAllowCustomCSSFlag(), cfg.AccountsAllowCustomCSS, fieldtag("AccountsAllowCustomCSS", "usage"))
// Media
- cmd.Flags().Uint64(MediaImageMaxSizeFlag(), uint64(cfg.MediaImageMaxSize), fieldtag("MediaImageMaxSize", "usage"))
- cmd.Flags().Uint64(MediaVideoMaxSizeFlag(), uint64(cfg.MediaVideoMaxSize), fieldtag("MediaVideoMaxSize", "usage"))
cmd.Flags().Int(MediaDescriptionMinCharsFlag(), cfg.MediaDescriptionMinChars, fieldtag("MediaDescriptionMinChars", "usage"))
cmd.Flags().Int(MediaDescriptionMaxCharsFlag(), cfg.MediaDescriptionMaxChars, fieldtag("MediaDescriptionMaxChars", "usage"))
cmd.Flags().Int(MediaRemoteCacheDaysFlag(), cfg.MediaRemoteCacheDays, fieldtag("MediaRemoteCacheDays", "usage"))
+ cmd.Flags().Uint64(MediaLocalMaxSizeFlag(), uint64(cfg.MediaLocalMaxSize), fieldtag("MediaLocalMaxSize", "usage"))
+ cmd.Flags().Uint64(MediaRemoteMaxSizeFlag(), uint64(cfg.MediaRemoteMaxSize), fieldtag("MediaRemoteMaxSize", "usage"))
cmd.Flags().Uint64(MediaEmojiLocalMaxSizeFlag(), uint64(cfg.MediaEmojiLocalMaxSize), fieldtag("MediaEmojiLocalMaxSize", "usage"))
cmd.Flags().Uint64(MediaEmojiRemoteMaxSizeFlag(), uint64(cfg.MediaEmojiRemoteMaxSize), fieldtag("MediaEmojiRemoteMaxSize", "usage"))
cmd.Flags().String(MediaCleanupFromFlag(), cfg.MediaCleanupFrom, fieldtag("MediaCleanupFrom", "usage"))
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 8dab7ac6a..8c27da439 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -1075,56 +1075,6 @@ func GetAccountsCustomCSSLength() int { return global.GetAccountsCustomCSSLength
// SetAccountsCustomCSSLength safely sets the value for global configuration 'AccountsCustomCSSLength' field
func SetAccountsCustomCSSLength(v int) { global.SetAccountsCustomCSSLength(v) }
-// GetMediaImageMaxSize safely fetches the Configuration value for state's 'MediaImageMaxSize' field
-func (st *ConfigState) GetMediaImageMaxSize() (v bytesize.Size) {
- st.mutex.RLock()
- v = st.config.MediaImageMaxSize
- st.mutex.RUnlock()
- return
-}
-
-// SetMediaImageMaxSize safely sets the Configuration value for state's 'MediaImageMaxSize' field
-func (st *ConfigState) SetMediaImageMaxSize(v bytesize.Size) {
- st.mutex.Lock()
- defer st.mutex.Unlock()
- st.config.MediaImageMaxSize = v
- st.reloadToViper()
-}
-
-// MediaImageMaxSizeFlag returns the flag name for the 'MediaImageMaxSize' field
-func MediaImageMaxSizeFlag() string { return "media-image-max-size" }
-
-// GetMediaImageMaxSize safely fetches the value for global configuration 'MediaImageMaxSize' field
-func GetMediaImageMaxSize() bytesize.Size { return global.GetMediaImageMaxSize() }
-
-// SetMediaImageMaxSize safely sets the value for global configuration 'MediaImageMaxSize' field
-func SetMediaImageMaxSize(v bytesize.Size) { global.SetMediaImageMaxSize(v) }
-
-// GetMediaVideoMaxSize safely fetches the Configuration value for state's 'MediaVideoMaxSize' field
-func (st *ConfigState) GetMediaVideoMaxSize() (v bytesize.Size) {
- st.mutex.RLock()
- v = st.config.MediaVideoMaxSize
- st.mutex.RUnlock()
- return
-}
-
-// SetMediaVideoMaxSize safely sets the Configuration value for state's 'MediaVideoMaxSize' field
-func (st *ConfigState) SetMediaVideoMaxSize(v bytesize.Size) {
- st.mutex.Lock()
- defer st.mutex.Unlock()
- st.config.MediaVideoMaxSize = v
- st.reloadToViper()
-}
-
-// MediaVideoMaxSizeFlag returns the flag name for the 'MediaVideoMaxSize' field
-func MediaVideoMaxSizeFlag() string { return "media-video-max-size" }
-
-// GetMediaVideoMaxSize safely fetches the value for global configuration 'MediaVideoMaxSize' field
-func GetMediaVideoMaxSize() bytesize.Size { return global.GetMediaVideoMaxSize() }
-
-// SetMediaVideoMaxSize safely sets the value for global configuration 'MediaVideoMaxSize' field
-func SetMediaVideoMaxSize(v bytesize.Size) { global.SetMediaVideoMaxSize(v) }
-
// GetMediaDescriptionMinChars safely fetches the Configuration value for state's 'MediaDescriptionMinChars' field
func (st *ConfigState) GetMediaDescriptionMinChars() (v int) {
st.mutex.RLock()
@@ -1250,6 +1200,56 @@ func GetMediaEmojiRemoteMaxSize() bytesize.Size { return global.GetMediaEmojiRem
// SetMediaEmojiRemoteMaxSize safely sets the value for global configuration 'MediaEmojiRemoteMaxSize' field
func SetMediaEmojiRemoteMaxSize(v bytesize.Size) { global.SetMediaEmojiRemoteMaxSize(v) }
+// GetMediaLocalMaxSize safely fetches the Configuration value for state's 'MediaLocalMaxSize' field
+func (st *ConfigState) GetMediaLocalMaxSize() (v bytesize.Size) {
+ st.mutex.RLock()
+ v = st.config.MediaLocalMaxSize
+ st.mutex.RUnlock()
+ return
+}
+
+// SetMediaLocalMaxSize safely sets the Configuration value for state's 'MediaLocalMaxSize' field
+func (st *ConfigState) SetMediaLocalMaxSize(v bytesize.Size) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.MediaLocalMaxSize = v
+ st.reloadToViper()
+}
+
+// MediaLocalMaxSizeFlag returns the flag name for the 'MediaLocalMaxSize' field
+func MediaLocalMaxSizeFlag() string { return "media-local-max-size" }
+
+// GetMediaLocalMaxSize safely fetches the value for global configuration 'MediaLocalMaxSize' field
+func GetMediaLocalMaxSize() bytesize.Size { return global.GetMediaLocalMaxSize() }
+
+// SetMediaLocalMaxSize safely sets the value for global configuration 'MediaLocalMaxSize' field
+func SetMediaLocalMaxSize(v bytesize.Size) { global.SetMediaLocalMaxSize(v) }
+
+// GetMediaRemoteMaxSize safely fetches the Configuration value for state's 'MediaRemoteMaxSize' field
+func (st *ConfigState) GetMediaRemoteMaxSize() (v bytesize.Size) {
+ st.mutex.RLock()
+ v = st.config.MediaRemoteMaxSize
+ st.mutex.RUnlock()
+ return
+}
+
+// SetMediaRemoteMaxSize safely sets the Configuration value for state's 'MediaRemoteMaxSize' field
+func (st *ConfigState) SetMediaRemoteMaxSize(v bytesize.Size) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.MediaRemoteMaxSize = v
+ st.reloadToViper()
+}
+
+// MediaRemoteMaxSizeFlag returns the flag name for the 'MediaRemoteMaxSize' field
+func MediaRemoteMaxSizeFlag() string { return "media-remote-max-size" }
+
+// GetMediaRemoteMaxSize safely fetches the value for global configuration 'MediaRemoteMaxSize' field
+func GetMediaRemoteMaxSize() bytesize.Size { return global.GetMediaRemoteMaxSize() }
+
+// SetMediaRemoteMaxSize safely sets the value for global configuration 'MediaRemoteMaxSize' field
+func SetMediaRemoteMaxSize(v bytesize.Size) { global.SetMediaRemoteMaxSize(v) }
+
// GetMediaCleanupFrom safely fetches the Configuration value for state's 'MediaCleanupFrom' field
func (st *ConfigState) GetMediaCleanupFrom() (v string) {
st.mutex.RLock()
diff --git a/internal/federation/dereferencing/emoji.go b/internal/federation/dereferencing/emoji.go
index 16f5acf25..806a3f5ee 100644
--- a/internal/federation/dereferencing/emoji.go
+++ b/internal/federation/dereferencing/emoji.go
@@ -23,6 +23,7 @@ import (
"io"
"net/url"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -90,9 +91,12 @@ func (d *Dereferencer) GetEmoji(
return nil, err
}
+ // Get maximum supported remote emoji size.
+ maxsz := config.GetMediaEmojiRemoteMaxSize()
+
// Prepare data function to dereference remote emoji media.
- data := func(context.Context) (io.ReadCloser, int64, error) {
- return tsport.DereferenceMedia(ctx, url)
+ data := func(context.Context) (io.ReadCloser, error) {
+ return tsport.DereferenceMedia(ctx, url, int64(maxsz))
}
// Pass along for safe processing.
@@ -171,9 +175,12 @@ func (d *Dereferencer) RefreshEmoji(
return nil, err
}
+ // Get maximum supported remote emoji size.
+ maxsz := config.GetMediaEmojiRemoteMaxSize()
+
// Prepare data function to dereference remote emoji media.
- data := func(context.Context) (io.ReadCloser, int64, error) {
- return tsport.DereferenceMedia(ctx, url)
+ data := func(context.Context) (io.ReadCloser, error) {
+ return tsport.DereferenceMedia(ctx, url, int64(maxsz))
}
// Pass along for safe processing.
diff --git a/internal/federation/dereferencing/emoji_test.go b/internal/federation/dereferencing/emoji_test.go
index fdb815762..12965207c 100644
--- a/internal/federation/dereferencing/emoji_test.go
+++ b/internal/federation/dereferencing/emoji_test.go
@@ -75,7 +75,7 @@ func (suite *EmojiTestSuite) TestDereferenceEmojiBlocking() {
suite.Equal("image/gif", emoji.ImageContentType)
suite.Equal("image/png", emoji.ImageStaticContentType)
suite.Equal(37796, emoji.ImageFileSize)
- suite.Equal(7951, emoji.ImageStaticFileSize)
+ suite.Equal(9824, emoji.ImageStaticFileSize)
suite.WithinDuration(time.Now(), emoji.UpdatedAt, 10*time.Second)
suite.False(*emoji.Disabled)
suite.Equal(emojiURI, emoji.URI)
diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go
index 874107b13..956866e94 100644
--- a/internal/federation/dereferencing/media.go
+++ b/internal/federation/dereferencing/media.go
@@ -22,6 +22,7 @@ import (
"io"
"net/url"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
@@ -69,12 +70,15 @@ func (d *Dereferencer) GetMedia(
return nil, gtserror.Newf("failed getting transport for %s: %w", requestUser, err)
}
+ // Get maximum supported remote media size.
+ maxsz := config.GetMediaRemoteMaxSize()
+
// Start processing remote attachment at URL.
processing, err := d.mediaManager.CreateMedia(
ctx,
accountID,
- func(ctx context.Context) (io.ReadCloser, int64, error) {
- return tsport.DereferenceMedia(ctx, url)
+ func(ctx context.Context) (io.ReadCloser, error) {
+ return tsport.DereferenceMedia(ctx, url, int64(maxsz))
},
info,
)
@@ -163,11 +167,14 @@ func (d *Dereferencer) RefreshMedia(
return nil, gtserror.Newf("failed getting transport for %s: %w", requestUser, err)
}
+ // Get maximum supported remote media size.
+ maxsz := config.GetMediaRemoteMaxSize()
+
// Start processing remote attachment recache.
processing := d.mediaManager.RecacheMedia(
media,
- func(ctx context.Context) (io.ReadCloser, int64, error) {
- return tsport.DereferenceMedia(ctx, url)
+ func(ctx context.Context) (io.ReadCloser, error) {
+ return tsport.DereferenceMedia(ctx, url, int64(maxsz))
},
)
diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go
index ba8760091..b78dbc2d9 100644
--- a/internal/httpclient/client.go
+++ b/internal/httpclient/client.go
@@ -31,7 +31,6 @@ import (
"strings"
"time"
- "codeberg.org/gruf/go-bytesize"
"codeberg.org/gruf/go-cache/v3"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-iotools"
@@ -89,9 +88,6 @@ type Config struct {
// WriteBufferSize: see http.Transport{}.WriteBufferSize.
WriteBufferSize int
- // MaxBodySize determines the maximum fetchable body size.
- MaxBodySize int64
-
// Timeout: see http.Client{}.Timeout.
Timeout time.Duration
@@ -111,7 +107,6 @@ type Config struct {
type Client struct {
client http.Client
badHosts cache.TTLCache[string, struct{}]
- bodyMax int64
retries uint
}
@@ -137,11 +132,6 @@ func New(cfg Config) *Client {
cfg.MaxIdleConns = cfg.MaxOpenConnsPerHost * 10
}
- if cfg.MaxBodySize <= 0 {
- // By default set this to a reasonable 40MB.
- cfg.MaxBodySize = int64(40 * bytesize.MiB)
- }
-
// Protect the dialer
// with IP range sanitizer.
d.Control = (&Sanitizer{
@@ -151,7 +141,6 @@ func New(cfg Config) *Client {
// Prepare client fields.
c.client.Timeout = cfg.Timeout
- c.bodyMax = cfg.MaxBodySize
// Prepare transport TLS config.
tlsClientConfig := &tls.Config{
@@ -377,31 +366,15 @@ func (c *Client) do(r *Request) (rsp *http.Response, retry bool, err error) {
rbody := (io.Reader)(rsp.Body)
cbody := (io.Closer)(rsp.Body)
- var limit int64
-
- if limit = rsp.ContentLength; limit < 0 {
- // If unknown, use max as reader limit.
- limit = c.bodyMax
- }
-
- // Don't trust them, limit body reads.
- rbody = io.LimitReader(rbody, limit)
-
- // Wrap closer to ensure entire body drained BEFORE close.
+ // Wrap closer to ensure body drained BEFORE close.
cbody = iotools.CloserAfterCallback(cbody, func() {
_, _ = discard.ReadFrom(rbody)
})
- // Wrap body with limit.
- rsp.Body = &struct {
- io.Reader
- io.Closer
- }{rbody, cbody}
-
- // Check response body not too large.
- if rsp.ContentLength > c.bodyMax {
- _ = rsp.Body.Close()
- return nil, false, ErrBodyTooLarge
+ // Set the wrapped response body.
+ rsp.Body = &iotools.ReadCloserType{
+ Reader: rbody,
+ Closer: cbody,
}
return rsp, true, nil
diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go
index f0ec01ec3..2e36a6e90 100644
--- a/internal/httpclient/client_test.go
+++ b/internal/httpclient/client_test.go
@@ -48,44 +48,19 @@ var bodies = []string{
"body with\r\nnewlines",
}
-func TestHTTPClientSmallBody(t *testing.T) {
+func TestHTTPClientBody(t *testing.T) {
for _, body := range bodies {
- _TestHTTPClientWithBody(t, []byte(body), int(^uint16(0)))
+ testHTTPClientWithBody(t, []byte(body))
}
}
-func TestHTTPClientExactBody(t *testing.T) {
- for _, body := range bodies {
- _TestHTTPClientWithBody(t, []byte(body), len(body))
- }
-}
-
-func TestHTTPClientLargeBody(t *testing.T) {
- for _, body := range bodies {
- _TestHTTPClientWithBody(t, []byte(body), len(body)-1)
- }
-}
-
-func _TestHTTPClientWithBody(t *testing.T, body []byte, max int) {
+func testHTTPClientWithBody(t *testing.T, body []byte) {
var (
handler http.HandlerFunc
-
- expect []byte
-
- expectErr error
)
- // If this is a larger body, reslice and
- // set error so we know what to expect
- expect = body
- if max < len(body) {
- expect = expect[:max]
- expectErr = httpclient.ErrBodyTooLarge
- }
-
// Create new HTTP client with maximum body size
client := httpclient.New(httpclient.Config{
- MaxBodySize: int64(max),
DisableCompression: true,
AllowRanges: []netip.Prefix{
// Loopback (used by server)
@@ -110,10 +85,8 @@ func _TestHTTPClientWithBody(t *testing.T, body []byte, max int) {
// Perform the test request
rsp, err := client.Do(req)
- if !errors.Is(err, expectErr) {
+ if err != nil {
t.Fatalf("error performing client request: %v", err)
- } else if err != nil {
- return // expected error
}
defer rsp.Body.Close()
@@ -124,8 +97,8 @@ func _TestHTTPClientWithBody(t *testing.T, body []byte, max int) {
}
// Check actual response body matches expected
- if !bytes.Equal(expect, check) {
- t.Errorf("response body did not match expected: expect=%q actual=%q", string(expect), string(check))
+ if !bytes.Equal(body, check) {
+ t.Errorf("response body did not match expected: expect=%q actual=%q", string(body), string(check))
}
}
diff --git a/internal/media/ffmpeg.go b/internal/media/ffmpeg.go
new file mode 100644
index 000000000..eb94849f0
--- /dev/null
+++ b/internal/media/ffmpeg.go
@@ -0,0 +1,313 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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"
+ "encoding/json"
+ "errors"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+
+ "codeberg.org/gruf/go-byteutil"
+
+ "codeberg.org/gruf/go-ffmpreg/wasm"
+ _ffmpeg "github.com/superseriousbusiness/gotosocial/internal/media/ffmpeg"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/tetratelabs/wazero"
+)
+
+// ffmpegClearMetadata generates a copy (in-place) of input media with all metadata cleared.
+func ffmpegClearMetadata(ctx context.Context, filepath string, ext string) error {
+ // Get directory from filepath.
+ dirpath := path.Dir(filepath)
+
+ // Generate output file path with ext.
+ outpath := filepath + "_cleaned." + ext
+
+ // Clear metadata with ffmpeg.
+ if err := ffmpeg(ctx, dirpath,
+ "-loglevel", "error",
+ "-i", filepath,
+ "-map_metadata", "-1",
+ "-codec", "copy",
+ "-y",
+ outpath,
+ ); err != nil {
+ return err
+ }
+
+ // Move the new output file path to original location.
+ if err := os.Rename(outpath, filepath); err != nil {
+ return gtserror.Newf("error renaming %s: %w", outpath, err)
+ }
+
+ return nil
+}
+
+// ffmpegGenerateThumb generates a thumbnail jpeg from input media of any type, useful for any media.
+func ffmpegGenerateThumb(ctx context.Context, filepath string, width, height int) (string, error) {
+ // Get directory from filepath.
+ dirpath := path.Dir(filepath)
+
+ // Generate output frame file path.
+ outpath := filepath + "_thumb.jpg"
+
+ // Generate thumb with ffmpeg.
+ if err := ffmpeg(ctx, dirpath,
+ "-loglevel", "error",
+ "-i", filepath,
+ "-filter:v", "thumbnail=n=10",
+ "-filter:v", "scale="+strconv.Itoa(width)+":"+strconv.Itoa(height),
+ "-qscale:v", "12", // ~ 70% quality
+ "-frames:v", "1",
+ "-y",
+ outpath,
+ ); err != nil {
+ return "", err
+ }
+
+ return outpath, nil
+}
+
+// ffmpegGenerateStatic generates a static png from input image of any type, useful for emoji.
+func ffmpegGenerateStatic(ctx context.Context, filepath string) (string, error) {
+ // Get directory from filepath.
+ dirpath := path.Dir(filepath)
+
+ // Generate output static file path.
+ outpath := filepath + "_static.png"
+
+ // Generate static with ffmpeg.
+ if err := ffmpeg(ctx, dirpath,
+ "-loglevel", "error",
+ "-i", filepath,
+ "-codec:v", "png", // specifically NOT 'apng'
+ "-frames:v", "1", // in case animated, only take 1 frame
+ "-y",
+ outpath,
+ ); err != nil {
+ return "", err
+ }
+
+ return outpath, nil
+}
+
+// ffmpeg calls `ffmpeg [args...]` (WASM) with directory path mounted in runtime.
+func ffmpeg(ctx context.Context, dirpath string, args ...string) error {
+ var stderr byteutil.Buffer
+ rc, err := _ffmpeg.Ffmpeg(ctx, wasm.Args{
+ Stderr: &stderr,
+ Args: args,
+ Config: func(modcfg wazero.ModuleConfig) wazero.ModuleConfig {
+ fscfg := wazero.NewFSConfig()
+ fscfg = fscfg.WithDirMount(dirpath, dirpath)
+ modcfg = modcfg.WithFSConfig(fscfg)
+ return modcfg
+ },
+ })
+ if err != nil {
+ return gtserror.Newf("error running: %w", err)
+ } else if rc != 0 {
+ return gtserror.Newf("non-zero return code %d (%s)", rc, stderr.B)
+ }
+ return nil
+}
+
+// ffprobe calls `ffprobe` (WASM) on filepath, returning parsed JSON output.
+func ffprobe(ctx context.Context, filepath string) (*ffprobeResult, error) {
+ var stdout byteutil.Buffer
+
+ // Get directory from filepath.
+ dirpath := path.Dir(filepath)
+
+ // Run ffprobe on our given file at path.
+ _, err := _ffmpeg.Ffprobe(ctx, wasm.Args{
+ Stdout: &stdout,
+
+ Args: []string{
+ "-i", filepath,
+ "-loglevel", "quiet",
+ "-print_format", "json",
+ "-show_streams",
+ "-show_format",
+ "-show_error",
+ },
+
+ Config: func(modcfg wazero.ModuleConfig) wazero.ModuleConfig {
+ fscfg := wazero.NewFSConfig()
+ fscfg = fscfg.WithReadOnlyDirMount(dirpath, dirpath)
+ modcfg = modcfg.WithFSConfig(fscfg)
+ return modcfg
+ },
+ })
+ if err != nil {
+ return nil, gtserror.Newf("error running: %w", err)
+ }
+
+ var result ffprobeResult
+
+ // Unmarshal the ffprobe output as our result type.
+ if err := json.Unmarshal(stdout.B, &result); err != nil {
+ return nil, gtserror.Newf("error unmarshaling json: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ffprobeResult contains parsed JSON data from
+// result of calling `ffprobe` on a media file.
+type ffprobeResult struct {
+ Streams []ffprobeStream `json:"streams"`
+ Format *ffprobeFormat `json:"format"`
+ Error *ffprobeError `json:"error"`
+}
+
+// ImageMeta extracts image metadata contained within ffprobe'd media result streams.
+func (res *ffprobeResult) ImageMeta() (width int, height int, err error) {
+ for _, stream := range res.Streams {
+ if stream.Width > width {
+ width = stream.Width
+ }
+ if stream.Height > height {
+ height = stream.Height
+ }
+ }
+ if width == 0 || height == 0 {
+ err = errors.New("invalid image stream(s)")
+ }
+ return
+}
+
+// VideoMeta extracts video metadata contained within ffprobe'd media result streams.
+func (res *ffprobeResult) VideoMeta() (width, height int, framerate float32, err error) {
+ for _, stream := range res.Streams {
+ if stream.Width > width {
+ width = stream.Width
+ }
+ if stream.Height > height {
+ height = stream.Height
+ }
+ if fr := stream.GetFrameRate(); fr > 0 {
+ if framerate == 0 || fr < framerate {
+ framerate = fr
+ }
+ }
+ }
+ if width == 0 || height == 0 || framerate == 0 {
+ err = errors.New("invalid video stream(s)")
+ }
+ return
+}
+
+type ffprobeStream struct {
+ CodecName string `json:"codec_name"`
+ AvgFrameRate string `json:"avg_frame_rate"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ // + unused fields.
+}
+
+// GetFrameRate calculates float32 framerate value from stream json string.
+func (str *ffprobeStream) GetFrameRate() float32 {
+ if str.AvgFrameRate != "" {
+ var (
+ // numerator
+ num float32
+
+ // denominator
+ den float32
+ )
+
+ // Check for a provided inequality, i.e. numerator / denominator.
+ if p := strings.SplitN(str.AvgFrameRate, "/", 2); len(p) == 2 {
+ n, _ := strconv.ParseFloat(p[0], 32)
+ d, _ := strconv.ParseFloat(p[1], 32)
+ num, den = float32(n), float32(d)
+ } else {
+ n, _ := strconv.ParseFloat(p[0], 32)
+ num = float32(n)
+ }
+
+ return num / den
+ }
+ return 0
+}
+
+type ffprobeFormat struct {
+ Filename string `json:"filename"`
+ FormatName string `json:"format_name"`
+ Duration string `json:"duration"`
+ BitRate string `json:"bit_rate"`
+ // + unused fields
+}
+
+// GetFileType determines file type and extension to use for media data.
+func (fmt *ffprobeFormat) GetFileType() (gtsmodel.FileType, string) {
+ switch fmt.FormatName {
+ case "mov,mp4,m4a,3gp,3g2,mj2":
+ return gtsmodel.FileTypeVideo, "mp4"
+ case "apng":
+ return gtsmodel.FileTypeImage, "apng"
+ case "png_pipe":
+ return gtsmodel.FileTypeImage, "png"
+ case "image2", "jpeg_pipe":
+ return gtsmodel.FileTypeImage, "jpeg"
+ case "webp_pipe":
+ return gtsmodel.FileTypeImage, "webp"
+ case "gif":
+ return gtsmodel.FileTypeImage, "gif"
+ case "mp3":
+ return gtsmodel.FileTypeAudio, "mp3"
+ case "ogg":
+ return gtsmodel.FileTypeAudio, "ogg"
+ default:
+ return gtsmodel.FileTypeUnknown, fmt.FormatName
+ }
+}
+
+// GetDuration calculates float32 framerate value from format json string.
+func (fmt *ffprobeFormat) GetDuration() float32 {
+ if fmt.Duration != "" {
+ dur, _ := strconv.ParseFloat(fmt.Duration, 32)
+ return float32(dur)
+ }
+ return 0
+}
+
+// GetBitRate calculates uint64 bitrate value from format json string.
+func (fmt *ffprobeFormat) GetBitRate() uint64 {
+ if fmt.BitRate != "" {
+ r, _ := strconv.ParseUint(fmt.BitRate, 10, 64)
+ return r
+ }
+ return 0
+}
+
+type ffprobeError struct {
+ Code int `json:"code"`
+ String string `json:"string"`
+}
+
+func (err *ffprobeError) Error() string {
+ return err.String + " (" + strconv.Itoa(err.Code) + ")"
+}
diff --git a/internal/media/ffmpeg/cache.go b/internal/media/ffmpeg/cache.go
new file mode 100644
index 000000000..371d409dc
--- /dev/null
+++ b/internal/media/ffmpeg/cache.go
@@ -0,0 +1,46 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 ffmpeg
+
+import (
+ "os"
+
+ "github.com/tetratelabs/wazero"
+)
+
+// shared WASM compilation cache.
+var cache wazero.CompilationCache
+
+func initCache() {
+ if cache != nil {
+ return
+ }
+
+ if dir := os.Getenv("WAZERO_COMPILATION_CACHE"); dir != "" {
+ var err error
+
+ // Use on-filesystem compilation cache given by env.
+ cache, err = wazero.NewCompilationCacheWithDir(dir)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ // Use in-memory compilation cache.
+ cache = wazero.NewCompilationCache()
+ }
+}
diff --git a/internal/media/ffmpeg/ffmpeg.go b/internal/media/ffmpeg/ffmpeg.go
new file mode 100644
index 000000000..357289fcc
--- /dev/null
+++ b/internal/media/ffmpeg/ffmpeg.go
@@ -0,0 +1,92 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 ffmpeg
+
+import (
+ "context"
+
+ ffmpeglib "codeberg.org/gruf/go-ffmpreg/embed/ffmpeg"
+ "codeberg.org/gruf/go-ffmpreg/util"
+ "codeberg.org/gruf/go-ffmpreg/wasm"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+)
+
+// InitFfmpeg initializes the ffmpeg WebAssembly instance pool,
+// with given maximum limiting the number of concurrent instances.
+func InitFfmpeg(ctx context.Context, max int) error {
+ initCache() // ensure compilation cache initialized
+ return ffmpegPool.Init(ctx, max)
+}
+
+// Ffmpeg runs the given arguments with an instance of ffmpeg.
+func Ffmpeg(ctx context.Context, args wasm.Args) (uint32, error) {
+ return ffmpegPool.Run(ctx, args)
+}
+
+var ffmpegPool = wasmInstancePool{
+ inst: wasm.Instantiator{
+
+ // WASM module name.
+ Module: "ffmpeg",
+
+ // Per-instance WebAssembly runtime (with shared cache).
+ Runtime: func(ctx context.Context) wazero.Runtime {
+
+ // Prepare config with cache.
+ cfg := wazero.NewRuntimeConfig()
+ cfg = cfg.WithCoreFeatures(ffmpeglib.CoreFeatures)
+ cfg = cfg.WithCompilationCache(cache)
+
+ // Instantiate runtime with our config.
+ rt := wazero.NewRuntimeWithConfig(ctx, cfg)
+
+ // Prepare default "env" host module.
+ env := rt.NewHostModuleBuilder("env")
+ env = env.NewFunctionBuilder().
+ WithGoModuleFunction(
+ api.GoModuleFunc(util.Wasm_Tempnam),
+ []api.ValueType{api.ValueTypeI32, api.ValueTypeI32},
+ []api.ValueType{api.ValueTypeI32},
+ ).
+ Export("tempnam")
+
+ // Instantiate "env" module in our runtime.
+ _, err := env.Instantiate(context.Background())
+ if err != nil {
+ panic(err)
+ }
+
+ // Instantiate the wasi snapshot preview 1 in runtime.
+ _, err = wasi_snapshot_preview1.Instantiate(ctx, rt)
+ if err != nil {
+ panic(err)
+ }
+
+ return rt
+ },
+
+ // Per-run module configuration.
+ Config: wazero.NewModuleConfig,
+
+ // Embedded WASM.
+ Source: ffmpeglib.B,
+ },
+}
diff --git a/internal/media/ffmpeg/ffprobe.go b/internal/media/ffmpeg/ffprobe.go
new file mode 100644
index 000000000..0b9660e60
--- /dev/null
+++ b/internal/media/ffmpeg/ffprobe.go
@@ -0,0 +1,92 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 ffmpeg
+
+import (
+ "context"
+
+ ffprobelib "codeberg.org/gruf/go-ffmpreg/embed/ffprobe"
+ "codeberg.org/gruf/go-ffmpreg/util"
+ "codeberg.org/gruf/go-ffmpreg/wasm"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+)
+
+// InitFfprobe initializes the ffprobe WebAssembly instance pool,
+// with given maximum limiting the number of concurrent instances.
+func InitFfprobe(ctx context.Context, max int) error {
+ initCache() // ensure compilation cache initialized
+ return ffprobePool.Init(ctx, max)
+}
+
+// Ffprobe runs the given arguments with an instance of ffprobe.
+func Ffprobe(ctx context.Context, args wasm.Args) (uint32, error) {
+ return ffprobePool.Run(ctx, args)
+}
+
+var ffprobePool = wasmInstancePool{
+ inst: wasm.Instantiator{
+
+ // WASM module name.
+ Module: "ffprobe",
+
+ // Per-instance WebAssembly runtime (with shared cache).
+ Runtime: func(ctx context.Context) wazero.Runtime {
+
+ // Prepare config with cache.
+ cfg := wazero.NewRuntimeConfig()
+ cfg = cfg.WithCoreFeatures(ffprobelib.CoreFeatures)
+ cfg = cfg.WithCompilationCache(cache)
+
+ // Instantiate runtime with our config.
+ rt := wazero.NewRuntimeWithConfig(ctx, cfg)
+
+ // Prepare default "env" host module.
+ env := rt.NewHostModuleBuilder("env")
+ env = env.NewFunctionBuilder().
+ WithGoModuleFunction(
+ api.GoModuleFunc(util.Wasm_Tempnam),
+ []api.ValueType{api.ValueTypeI32, api.ValueTypeI32},
+ []api.ValueType{api.ValueTypeI32},
+ ).
+ Export("tempnam")
+
+ // Instantiate "env" module in our runtime.
+ _, err := env.Instantiate(context.Background())
+ if err != nil {
+ panic(err)
+ }
+
+ // Instantiate the wasi snapshot preview 1 in runtime.
+ _, err = wasi_snapshot_preview1.Instantiate(ctx, rt)
+ if err != nil {
+ panic(err)
+ }
+
+ return rt
+ },
+
+ // Per-run module configuration.
+ Config: wazero.NewModuleConfig,
+
+ // Embedded WASM.
+ Source: ffprobelib.B,
+ },
+}
diff --git a/internal/media/ffmpeg/pool.go b/internal/media/ffmpeg/pool.go
new file mode 100644
index 000000000..9f6446be3
--- /dev/null
+++ b/internal/media/ffmpeg/pool.go
@@ -0,0 +1,75 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 ffmpeg
+
+import (
+ "context"
+
+ "codeberg.org/gruf/go-ffmpreg/wasm"
+)
+
+// wasmInstancePool wraps a wasm.Instantiator{} and a
+// channel of wasm.Instance{}s to provide a concurrency
+// safe pool of WebAssembly module instances capable of
+// compiling new instances on-the-fly, with a predetermined
+// maximum number of concurrent instances at any one time.
+type wasmInstancePool struct {
+ inst wasm.Instantiator
+ pool chan *wasm.Instance
+}
+
+func (p *wasmInstancePool) Init(ctx context.Context, sz int) error {
+ p.pool = make(chan *wasm.Instance, sz)
+ for i := 0; i < sz; i++ {
+ inst, err := p.inst.New(ctx)
+ if err != nil {
+ return err
+ }
+ p.pool <- inst
+ }
+ return nil
+}
+
+func (p *wasmInstancePool) Run(ctx context.Context, args wasm.Args) (uint32, error) {
+ var inst *wasm.Instance
+
+ select {
+ // Context canceled.
+ case <-ctx.Done():
+ return 0, ctx.Err()
+
+ // Acquire instance.
+ case inst = <-p.pool:
+
+ // Ensure instance is
+ // ready for running.
+ if inst.IsClosed() {
+ var err error
+ inst, err = p.inst.New(ctx)
+ if err != nil {
+ return 0, err
+ }
+ }
+ }
+
+ // Release instance to pool on end.
+ defer func() { p.pool <- inst }()
+
+ // Pass args to instance.
+ return inst.Run(ctx, args)
+}
diff --git a/internal/media/image.go b/internal/media/image.go
deleted file mode 100644
index 8a34e5062..000000000
--- a/internal/media/image.go
+++ /dev/null
@@ -1,189 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// 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 (
- "bufio"
- "image"
- "image/color"
- "image/draw"
- "image/jpeg"
- "image/png"
- "io"
- "sync"
-
- "github.com/buckket/go-blurhash"
- "github.com/disintegration/imaging"
- "github.com/superseriousbusiness/gotosocial/internal/iotools"
-
- // import to init webp encode/decoding.
- _ "golang.org/x/image/webp"
-)
-
-var (
- // pngEncoder provides our global PNG encoding with
- // specified compression level, and memory pooled buffers.
- pngEncoder = png.Encoder{
- CompressionLevel: png.DefaultCompression,
- BufferPool: &pngEncoderBufferPool{},
- }
-
- // jpegBufferPool is a memory pool
- // of byte buffers for JPEG encoding.
- jpegBufferPool sync.Pool
-)
-
-// gtsImage is a thin wrapper around the standard library image
-// interface to provide our own useful helper functions for image
-// size and aspect ratio calculations, streamed encoding to various
-// types, and creating reduced size thumbnail images.
-type gtsImage struct{ image image.Image }
-
-// blankImage generates a blank image of given dimensions.
-func blankImage(width int, height int) *gtsImage {
- // create a rectangle with the same dimensions as the video
- img := image.NewRGBA(image.Rect(0, 0, width, height))
-
- // fill the rectangle with our desired fill color.
- draw.Draw(img, img.Bounds(), &image.Uniform{
- color.RGBA{42, 43, 47, 0},
- }, image.Point{}, draw.Src)
-
- return &gtsImage{image: img}
-}
-
-// decodeImage will decode image from reader stream and return image wrapped in our own gtsImage{} type.
-func decodeImage(r io.Reader, opts ...imaging.DecodeOption) (*gtsImage, error) {
- img, err := imaging.Decode(r, opts...)
- if err != nil {
- return nil, err
- }
- return &gtsImage{image: img}, nil
-}
-
-// Width returns the image width in pixels.
-func (m *gtsImage) Width() int {
- return m.image.Bounds().Size().X
-}
-
-// Height returns the image height in pixels.
-func (m *gtsImage) Height() int {
- return m.image.Bounds().Size().Y
-}
-
-// Size returns the total number of image pixels.
-func (m *gtsImage) Size() int {
- return m.image.Bounds().Size().X *
- m.image.Bounds().Size().Y
-}
-
-// AspectRatio returns the image ratio of width:height.
-func (m *gtsImage) AspectRatio() float32 {
-
- // note: we cast bounds to float64 to prevent truncation
- // and only at the end aspect ratio do we cast to float32
- // (as the sizes are likely to be much larger than ratio).
- return float32(float64(m.image.Bounds().Size().X) /
- float64(m.image.Bounds().Size().Y))
-}
-
-// Thumbnail returns a small sized copy of gtsImage{}, limited to 512x512 if not small enough.
-func (m *gtsImage) Thumbnail() *gtsImage {
- const (
- // max thumb
- // dimensions.
- maxWidth = 512
- maxHeight = 512
- )
-
- // Check the receiving image is within max thumnail bounds.
- if m.Width() <= maxWidth && m.Height() <= maxHeight {
- return &gtsImage{image: imaging.Clone(m.image)}
- }
-
- // Image is too large, needs to be resized to thumbnail max.
- img := imaging.Fit(m.image, maxWidth, maxHeight, imaging.Linear)
- return &gtsImage{image: img}
-}
-
-// Blurhash calculates the blurhash for the receiving image data.
-func (m *gtsImage) Blurhash() (string, error) {
- // for generating blurhashes, it's more cost effective to
- // lose detail since it's blurry, so make a tiny version.
- tiny := imaging.Resize(m.image, 32, 0, imaging.NearestNeighbor)
-
- // Encode blurhash from resized version
- return blurhash.Encode(4, 3, tiny)
-}
-
-// ToJPEG creates a new streaming JPEG encoder from receiving image, and a size ptr
-// which stores the number of bytes written during the image encoding process.
-func (m *gtsImage) ToJPEG(opts *jpeg.Options) io.Reader {
- return iotools.StreamWriteFunc(func(w io.Writer) error {
- // Get encoding buffer
- bw := getJPEGBuffer(w)
-
- // Encode JPEG to buffered writer.
- err := jpeg.Encode(bw, m.image, opts)
-
- // Replace buffer.
- //
- // NOTE: jpeg.Encode() already
- // performs a bufio.Writer.Flush().
- putJPEGBuffer(bw)
-
- return err
- })
-}
-
-// ToPNG creates a new streaming PNG encoder from receiving image, and a size ptr
-// which stores the number of bytes written during the image encoding process.
-func (m *gtsImage) ToPNG() io.Reader {
- return iotools.StreamWriteFunc(func(w io.Writer) error {
- return pngEncoder.Encode(w, m.image)
- })
-}
-
-// getJPEGBuffer fetches a reset JPEG encoding buffer from global JPEG buffer pool.
-func getJPEGBuffer(w io.Writer) *bufio.Writer {
- v := jpegBufferPool.Get()
- if v == nil {
- v = bufio.NewWriter(nil)
- }
- buf := v.(*bufio.Writer)
- buf.Reset(w)
- return buf
-}
-
-// putJPEGBuffer resets the given bufio writer and places in global JPEG buffer pool.
-func putJPEGBuffer(buf *bufio.Writer) {
- buf.Reset(nil)
- jpegBufferPool.Put(buf)
-}
-
-// pngEncoderBufferPool implements png.EncoderBufferPool.
-type pngEncoderBufferPool sync.Pool
-
-func (p *pngEncoderBufferPool) Get() *png.EncoderBuffer {
- buf, _ := (*sync.Pool)(p).Get().(*png.EncoderBuffer)
- return buf
-}
-
-func (p *pngEncoderBufferPool) Put(buf *png.EncoderBuffer) {
- (*sync.Pool)(p).Put(buf)
-}
diff --git a/internal/media/manager.go b/internal/media/manager.go
index ea126e460..aaf9448b8 100644
--- a/internal/media/manager.go
+++ b/internal/media/manager.go
@@ -314,21 +314,26 @@ func (m *Manager) RefreshEmoji(
// Since this is a refresh we will end up storing new images at new
// paths, so we should wrap closer to delete old paths at completion.
- wrapped := func(ctx context.Context) (io.ReadCloser, int64, error) {
+ wrapped := func(ctx context.Context) (io.ReadCloser, error) {
- // Call original data func.
- rc, sz, err := data(ctx)
+ // Call original func.
+ rc, err := data(ctx)
if err != nil {
- return nil, 0, err
+ return nil, err
}
- // Wrap closer to cleanup old data.
- c := iotools.CloserFunc(func() error {
+ // Cast as separated reader / closer types.
+ rct, ok := rc.(*iotools.ReadCloserType)
- // First try close original.
- if rc.Close(); err != nil {
- return err
- }
+ if !ok {
+ // Allocate new read closer type.
+ rct = new(iotools.ReadCloserType)
+ rct.Reader = rc
+ rct.Closer = rc
+ }
+
+ // Wrap underlying io.Closer type to cleanup old data.
+ rct.Closer = iotools.CloserCallback(rct.Closer, func() {
// Remove any *old* emoji image file path now stream is closed.
if err := m.state.Storage.Delete(ctx, oldPath); err != nil &&
@@ -341,12 +346,9 @@ func (m *Manager) RefreshEmoji(
!storage.IsNotFound(err) {
log.Errorf(ctx, "error deleting old static emoji %s from storage: %v", shortcodeDomain, err)
}
-
- return nil
})
- // Return newly wrapped readcloser and size.
- return iotools.ReadCloser(rc, c), sz, nil
+ return rct, nil
}
// Use a new ID to create a new path
diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go
index 53c08eed8..a099d2b95 100644
--- a/internal/media/manager_test.go
+++ b/internal/media/manager_test.go
@@ -20,12 +20,14 @@ package media_test
import (
"bytes"
"context"
+ "crypto/md5"
"fmt"
"io"
"os"
"testing"
"time"
+ "codeberg.org/gruf/go-iotools"
"codeberg.org/gruf/go-storage/disk"
"github.com/stretchr/testify/suite"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -33,6 +35,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
gtsstorage "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -43,13 +46,13 @@ type ManagerTestSuite struct {
func (suite *ManagerTestSuite) TestEmojiProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/rainbow-original.png")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
processing, err := suite.manager.CreateEmoji(ctx,
@@ -66,7 +69,7 @@ func (suite *ManagerTestSuite) TestEmojiProcess() {
suite.NotNil(emoji)
// file meta should be correctly derived from the image
- suite.Equal("image/png", emoji.ImageContentType)
+ suite.Equal("image/apng", emoji.ImageContentType)
suite.Equal("image/png", emoji.ImageStaticContentType)
suite.Equal(36702, emoji.ImageFileSize)
@@ -75,29 +78,9 @@ func (suite *ManagerTestSuite) TestEmojiProcess() {
suite.NoError(err)
suite.NotNil(dbEmoji)
- // make sure the processed emoji file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, emoji.ImagePath)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/rainbow-original.png")
- 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
- processedStaticBytes, err := suite.storage.Get(ctx, emoji.ImageStaticPath)
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytes)
-
- processedStaticBytesExpected, err := os.ReadFile("./test/rainbow-static.png")
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytesExpected)
-
- suite.Equal(processedStaticBytesExpected, processedStaticBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImagePath, "./test/rainbow-original.png")
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImageStaticPath, "./test/rainbow-static.png")
}
func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
@@ -114,12 +97,12 @@ func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
oldEmojiImagePath := emojiToUpdate.ImagePath
oldEmojiImageStaticPath := emojiToUpdate.ImageStaticPath
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
b, err := os.ReadFile("./test/gts_pixellated-original.png")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
processing, err := suite.manager.RefreshEmoji(ctx,
@@ -151,29 +134,9 @@ func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
suite.NoError(err)
suite.NotNil(dbEmoji)
- // make sure the processed emoji file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, emoji.ImagePath)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/gts_pixellated-original.png")
- 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
- processedStaticBytes, err := suite.storage.Get(ctx, emoji.ImageStaticPath)
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytes)
-
- processedStaticBytesExpected, err := os.ReadFile("./test/gts_pixellated-static.png")
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytesExpected)
-
- suite.Equal(processedStaticBytesExpected, processedStaticBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImagePath, "./test/gts_pixellated-original.png")
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImageStaticPath, "./test/gts_pixellated-static.png")
// most fields should be different on the emoji now from what they were before
suite.Equal(originalEmoji.ID, dbEmoji.ID)
@@ -197,124 +160,47 @@ func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
func (suite *ManagerTestSuite) TestEmojiProcessTooLarge() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test image
- b, err := os.ReadFile("./test/big-panda.gif")
- if err != nil {
- panic(err)
- }
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ // Open test image as file for reading.
+ file, err := os.Open("./test/big-panda.gif")
+ if err != nil {
+ panic(err)
}
- processing, err := suite.manager.CreateEmoji(ctx,
- "big_panda",
- "",
- data,
- media.AdditionalEmojiInfo{},
- )
- suite.NoError(err)
-
- // do a blocking call to fetch the emoji
- _, err = processing.Load(ctx)
- suite.EqualError(err, "store: given emoji size 630kiB greater than max allowed 50.0kiB")
-}
-
-func (suite *ManagerTestSuite) TestEmojiProcessTooLargeNoSizeGiven() {
- ctx := context.Background()
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test image
- b, err := os.ReadFile("./test/big-panda.gif")
- if err != nil {
- panic(err)
- }
- return io.NopCloser(bytes.NewBuffer(b)), -1, nil
+ // Get file size info.
+ stat, err := file.Stat()
+ if err != nil {
+ panic(err)
}
+ // Set max allowed size UNDER image size.
+ lr := io.LimitReader(file, stat.Size()-10)
+ rc := iotools.ReadCloser(lr, file)
+
processing, err := suite.manager.CreateEmoji(ctx,
"big_panda",
"",
- data,
+ func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return rc, nil
+ },
media.AdditionalEmojiInfo{},
)
suite.NoError(err)
// do a blocking call to fetch the emoji
_, err = processing.Load(ctx)
- suite.EqualError(err, "store: written emoji size 630kiB greater than max allowed 50.0kiB")
-}
-
-func (suite *ManagerTestSuite) TestEmojiProcessNoFileSizeGiven() {
- ctx := context.Background()
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test image
- b, err := os.ReadFile("./test/rainbow-original.png")
- if err != nil {
- panic(err)
- }
- return io.NopCloser(bytes.NewBuffer(b)), -1, nil
- }
-
- // process the media with no additional info provided
- processing, err := suite.manager.CreateEmoji(ctx,
- "rainbow_test",
- "",
- data,
- media.AdditionalEmojiInfo{},
- )
- suite.NoError(err)
-
- // do a blocking call to fetch the emoji
- emoji, err := processing.Load(ctx)
- suite.NoError(err)
- suite.NotNil(emoji)
-
- // file meta should be correctly derived from the image
- suite.Equal("image/png", emoji.ImageContentType)
- suite.Equal("image/png", emoji.ImageStaticContentType)
- suite.Equal(36702, emoji.ImageFileSize)
-
- // now make sure the emoji is in the database
- dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID)
- suite.NoError(err)
- suite.NotNil(dbEmoji)
-
- // make sure the processed emoji file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, emoji.ImagePath)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/rainbow-original.png")
- 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
- processedStaticBytes, err := suite.storage.Get(ctx, emoji.ImageStaticPath)
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytes)
-
- processedStaticBytesExpected, err := os.ReadFile("./test/rainbow-static.png")
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytesExpected)
-
- suite.Equal(processedStaticBytesExpected, processedStaticBytes)
+ suite.EqualError(err, "store: error draining data to tmp: reached read limit 630kiB")
}
func (suite *ManagerTestSuite) TestEmojiWebpProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/nb-flag-original.webp")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
// process the media with no additional info provided
@@ -341,41 +227,21 @@ func (suite *ManagerTestSuite) TestEmojiWebpProcess() {
suite.NoError(err)
suite.NotNil(dbEmoji)
- // make sure the processed emoji file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, emoji.ImagePath)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/nb-flag-original.webp")
- 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
- processedStaticBytes, err := suite.storage.Get(ctx, emoji.ImageStaticPath)
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytes)
-
- processedStaticBytesExpected, err := os.ReadFile("./test/nb-flag-static.png")
- suite.NoError(err)
- suite.NotEmpty(processedStaticBytesExpected)
-
- suite.Equal(processedStaticBytesExpected, processedStaticBytes)
+ // ensure files are equal
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImagePath, "./test/nb-flag-original.webp")
+ equalFiles(suite.T(), suite.state.Storage, dbEmoji.ImageStaticPath, "./test/nb-flag-static.png")
}
func (suite *ManagerTestSuite) TestSimpleJpegProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/test-jpeg.jpg")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -409,117 +275,66 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcess() {
suite.Equal("image/jpeg", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
+ suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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(ctx, 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)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpg")
}
-func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() {
+func (suite *ManagerTestSuite) TestSimpleJpegProcessTooLarge() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test image
- b, err := os.ReadFile("./test/test-jpeg.jpg")
- if err != nil {
- panic(err)
- }
-
- // Fuck up the bytes a bit by cutting
- // off the second half, tee hee!
- b = b[:len(b)/2]
+ // Open test image as file for reading.
+ file, err := os.Open("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ // Get file size info.
+ stat, err := file.Stat()
+ if err != nil {
+ panic(err)
}
+ // Set max allowed size UNDER image size.
+ lr := io.LimitReader(file, stat.Size()-10)
+ rc := iotools.ReadCloser(lr, file)
+
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
processing, err := suite.manager.CreateMedia(ctx,
accountID,
- data,
+ func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return rc, nil
+ },
media.AdditionalMediaInfo{},
)
suite.NoError(err)
suite.NotNil(processing)
// do a blocking call to fetch the attachment
- attachment, err := processing.Load(ctx)
-
- // Since we're cutting off the byte stream
- // halfway through, we should get an error here.
- suite.EqualError(err, "store: error writing media to storage: scan-data is unbounded; EOI not encountered before EOF")
- 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(processing.ID(), attachment.ID)
- suite.Equal(accountID, attachment.AccountID)
-
- // file meta should be correctly derived from the image
- suite.Zero(attachment.FileMeta)
- suite.Equal("image/jpeg", attachment.File.ContentType)
- suite.Empty(attachment.Blurhash)
-
- // now make sure the attachment is in the database
- dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
- suite.NoError(err)
- suite.NotNil(dbAttachment)
-
- // Attachment should have type unknown
- suite.Equal(gtsmodel.FileTypeUnknown, dbAttachment.Type)
-
- // Nothing should be in storage for this attachment.
- stored, err := suite.storage.Has(ctx, attachment.File.Path)
- if err != nil {
- suite.FailNow(err.Error())
- }
- suite.False(stored)
-
- stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
- if err != nil {
- suite.FailNow(err.Error())
- }
- suite.False(stored)
+ _, err = processing.Load(ctx)
+ suite.EqualError(err, "store: error draining data to tmp: reached read limit 263kiB")
}
func (suite *ManagerTestSuite) TestPDFProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from Frantz
b, err := os.ReadFile("./test/Frantz-Fanon-The-Wretched-of-the-Earth-1965.pdf")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -545,7 +360,7 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
// file meta should be correctly derived from the image
suite.Zero(attachment.FileMeta)
- suite.Equal("application/pdf", attachment.File.ContentType)
+ suite.Equal("application/octet-stream", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Empty(attachment.Blurhash)
@@ -559,28 +374,24 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
// Nothing should be in storage for this attachment.
stored, err := suite.storage.Has(ctx, attachment.File.Path)
- if err != nil {
- suite.FailNow(err.Error())
- }
+ suite.NoError(err)
suite.False(stored)
stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
- if err != nil {
- suite.FailNow(err.Error())
- }
+ suite.NoError(err)
suite.False(stored)
}
func (suite *ManagerTestSuite) TestSlothVineProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test video
b, err := os.ReadFile("./test/test-mp4-original.mp4")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -609,57 +420,37 @@ func (suite *ManagerTestSuite) TestSlothVineProcess() {
suite.Equal(240, attachment.FileMeta.Original.Height)
suite.Equal(81120, attachment.FileMeta.Original.Size)
suite.EqualValues(float32(1.4083333), attachment.FileMeta.Original.Aspect)
- suite.EqualValues(float32(6.640907), *attachment.FileMeta.Original.Duration)
- suite.EqualValues(float32(29.000029), *attachment.FileMeta.Original.Framerate)
- suite.EqualValues(0x59e74, *attachment.FileMeta.Original.Bitrate)
+ suite.EqualValues(float32(6.641), *attachment.FileMeta.Original.Duration)
+ suite.EqualValues(float32(29.00003), *attachment.FileMeta.Original.Framerate)
+ suite.EqualValues(0x5be18, *attachment.FileMeta.Original.Bitrate)
suite.EqualValues(gtsmodel.Small{
Width: 338, Height: 240, Size: 81120, Aspect: 1.4083333333333334,
}, attachment.FileMeta.Small)
suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(312413, attachment.File.FileSize)
- suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash)
+ suite.Equal(312453, attachment.File.FileSize)
+ suite.Equal("LrJuJat6NZkBt7ayW.j[_4WBsWoL", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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-mp4-processed.mp4")
- 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(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/test-mp4-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-mp4-processed.mp4")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-mp4-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestLongerMp4Process() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test video
b, err := os.ReadFile("./test/longer-mp4-original.mp4")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -690,55 +481,35 @@ func (suite *ManagerTestSuite) TestLongerMp4Process() {
suite.EqualValues(float32(1.8181819), attachment.FileMeta.Original.Aspect)
suite.EqualValues(float32(16.6), *attachment.FileMeta.Original.Duration)
suite.EqualValues(float32(10), *attachment.FileMeta.Original.Framerate)
- suite.EqualValues(0xc8fb, *attachment.FileMeta.Original.Bitrate)
+ suite.EqualValues(0xce3a, *attachment.FileMeta.Original.Bitrate)
suite.EqualValues(gtsmodel.Small{
Width: 512, Height: 281, Size: 143872, Aspect: 1.822064,
}, attachment.FileMeta.Small)
suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(109549, attachment.File.FileSize)
- suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash)
+ suite.Equal(109569, attachment.File.FileSize)
+ suite.Equal("LASY{q~qD%_3~qD%ofRjM{ofofRj", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, attachment.File.Path)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/longer-mp4-processed.mp4")
- 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(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/longer-mp4-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/longer-mp4-processed.mp4")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/longer-mp4-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestBirdnestMp4Process() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test video
b, err := os.ReadFile("./test/birdnest-original.mp4")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -767,169 +538,37 @@ func (suite *ManagerTestSuite) TestBirdnestMp4Process() {
suite.Equal(720, attachment.FileMeta.Original.Height)
suite.Equal(290880, attachment.FileMeta.Original.Size)
suite.EqualValues(float32(0.5611111), attachment.FileMeta.Original.Aspect)
- suite.EqualValues(float32(9.822041), *attachment.FileMeta.Original.Duration)
+ suite.EqualValues(float32(9.823), *attachment.FileMeta.Original.Duration)
suite.EqualValues(float32(30), *attachment.FileMeta.Original.Framerate)
- suite.EqualValues(0x117c79, *attachment.FileMeta.Original.Bitrate)
+ suite.EqualValues(0x11844c, *attachment.FileMeta.Original.Bitrate)
suite.EqualValues(gtsmodel.Small{
Width: 287, Height: 512, Size: 146944, Aspect: 0.5605469,
}, attachment.FileMeta.Small)
suite.Equal("video/mp4", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(1409577, attachment.File.FileSize)
- suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash)
+ suite.Equal(1409625, attachment.File.FileSize)
+ suite.Equal("LOGb||RjRO.99DRORPaetkV?afMw", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, attachment.File.Path)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/birdnest-processed.mp4")
- 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(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/birdnest-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/birdnest-processed.mp4")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/birdnest-thumbnail.jpg")
}
-func (suite *ManagerTestSuite) TestNotAnMp4Process() {
- // try to load an 'mp4' that's actually an mkv in disguise
-
+func (suite *ManagerTestSuite) TestOpusProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test video
- b, err := os.ReadFile("./test/not-an.mp4")
- if err != nil {
- panic(err)
- }
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
- }
-
- accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
-
- // pre processing should go fine but...
- processing, err := suite.manager.CreateMedia(ctx,
- accountID,
- data,
- media.AdditionalMediaInfo{},
- )
- suite.NoError(err)
- suite.NotNil(processing)
-
- // we should get an error while loading
- attachment, err := processing.Load(ctx)
- suite.EqualError(err, "finish: error decoding video: error determining video metadata: [width height framerate]")
-
- // partial attachment should be
- // returned, with 'unknown' type.
- suite.NotNil(attachment)
- suite.Equal(gtsmodel.FileTypeUnknown, attachment.Type)
-}
-
-func (suite *ManagerTestSuite) TestSimpleJpegProcessNoContentLengthGiven() {
- ctx := context.Background()
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
- b, err := os.ReadFile("./test/test-jpeg.jpg")
- if err != nil {
- panic(err)
- }
- // give length as -1 to indicate unknown
- return io.NopCloser(bytes.NewBuffer(b)), -1, nil
- }
-
- accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
-
- // process the media with no additional info provided
- processing, err := suite.manager.CreateMedia(ctx,
- accountID,
- data,
- media.AdditionalMediaInfo{},
- )
- suite.NoError(err)
- suite.NotNil(processing)
-
- // do a blocking call to fetch the attachment
- attachment, err := processing.Load(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(processing.ID(), 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("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
-
- // now make sure the attachment is in the database
- dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
- suite.NoError(err)
- suite.NotNil(dbAttachment)
-
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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(ctx, 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) TestSimpleJpegProcessReadCloser() {
- ctx := context.Background()
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // open test image as a file
- f, err := os.Open("./test/test-jpeg.jpg")
+ b, err := os.ReadFile("./test/test-opus-original.opus")
if err != nil {
panic(err)
}
- // give length as -1 to indicate unknown
- return f, -1, nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -955,56 +594,33 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessReadCloser() {
// file meta should be correctly derived from the image
suite.EqualValues(gtsmodel.Original{
- Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
+ Duration: util.Ptr(float32(122.10006)),
+ Bitrate: util.Ptr(uint64(116426)),
}, 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("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
+ suite.Equal("audio/ogg", attachment.File.ContentType)
+ suite.Equal(1776956, attachment.File.FileSize)
+ suite.Empty(attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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(ctx, 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)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-opus-processed.opus")
+ suite.Zero(dbAttachment.Thumbnail.FileSize)
}
func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/test-png-noalphachannel.png")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -1038,48 +654,28 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() {
suite.Equal("image/png", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Equal(17471, attachment.File.FileSize)
- suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash)
+ suite.Equal("LDQJl?%i-?WG%go#RURP~of3~UxV", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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-png-noalphachannel-processed.png")
- 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(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/test-png-noalphachannel-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-noalphachannel-processed.png")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-noalphachannel-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/test-png-alphachannel.png")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -1113,48 +709,28 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() {
suite.Equal("image/png", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Equal(18904, attachment.File.FileSize)
- suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash)
+ suite.Equal("LDQJl?%i-?WG%go#RURP~of3~UxV", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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-png-alphachannel-processed.png")
- 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(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/test-png-alphachannel-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-alphachannel-processed.png")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-alphachannel-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/test-jpeg.jpg")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -1188,53 +764,33 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() {
suite.Equal("image/jpeg", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
+ suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, 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(ctx, 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)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg")
+ equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() {
ctx := context.Background()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile("./test/test-jpeg.jpg")
if err != nil {
panic(err)
}
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
- temp := fmt.Sprintf("%s/gotosocial-test", os.TempDir())
+ temp := fmt.Sprintf("./%s/gotosocial-test", os.TempDir())
defer os.RemoveAll(temp)
disk, err := disk.Open(temp, nil)
@@ -1285,36 +841,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() {
suite.Equal("image/jpeg", attachment.File.ContentType)
suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
+ suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
- // make sure the processed file is in storage
- processedFullBytes, err := storage.Get(ctx, 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 := storage.Get(ctx, 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)
+ // ensure the files contain the expected data.
+ equalFiles(suite.T(), storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg")
+ equalFiles(suite.T(), storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpg")
}
func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() {
@@ -1348,12 +884,12 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() {
ctx, cncl := context.WithTimeout(context.Background(), time.Second*60)
defer cncl()
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ data := func(_ context.Context) (io.ReadCloser, error) {
// load bytes from a test image
b, err := os.ReadFile(test.path)
suite.NoError(err, "Test %d: failed during test setup", index+1)
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ return io.NopCloser(bytes.NewBuffer(b)), nil
}
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
@@ -1390,78 +926,23 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() {
}
}
-func (suite *ManagerTestSuite) TestMisreportedSmallMedia() {
- const accountID = "01FS1X72SK9ZPW0J1QQ68BD264"
- var actualSize int
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // Load bytes from small png.
- b, err := os.ReadFile("./test/test-png-alphachannel-1x1px.png")
- if err != nil {
- suite.FailNow(err.Error())
- }
-
- actualSize = len(b)
-
- // Report media as twice its actual size. This should be corrected.
- return io.NopCloser(bytes.NewBuffer(b)), int64(2 * actualSize), nil
- }
-
- ctx := context.Background()
-
- // process the media with no additional info provided
- processing, err := suite.manager.CreateMedia(ctx,
- accountID,
- data,
- media.AdditionalMediaInfo{},
- )
- suite.NoError(err)
- suite.NotNil(processing)
-
- // do a blocking call to fetch the attachment
- attachment, err := processing.Load(ctx)
- suite.NoError(err)
- suite.NotNil(attachment)
-
- suite.Equal(actualSize, attachment.File.FileSize)
+func TestManagerTestSuite(t *testing.T) {
+ suite.Run(t, &ManagerTestSuite{})
}
-func (suite *ManagerTestSuite) TestNoReportedSizeSmallMedia() {
- const accountID = "01FS1X72SK9ZPW0J1QQ68BD264"
- var actualSize int
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // Load bytes from small png.
- b, err := os.ReadFile("./test/test-png-alphachannel-1x1px.png")
- if err != nil {
- suite.FailNow(err.Error())
- }
-
- actualSize = len(b)
-
- // Return zero for media size. This should be detected.
- return io.NopCloser(bytes.NewBuffer(b)), 0, nil
+// equalFiles checks whether
+func equalFiles(t *testing.T, st *storage.Driver, storagePath, testPath string) {
+ b1, err := st.Get(context.Background(), storagePath)
+ if err != nil {
+ t.Fatalf("error reading file %s: %v", storagePath, err)
}
- ctx := context.Background()
-
- // process the media with no additional info provided
- processing, err := suite.manager.CreateMedia(ctx,
- accountID,
- data,
- media.AdditionalMediaInfo{},
- )
- suite.NoError(err)
- suite.NotNil(processing)
-
- // do a blocking call to fetch the attachment
- attachment, err := processing.Load(ctx)
- suite.NoError(err)
- suite.NotNil(attachment)
-
- suite.Equal(actualSize, attachment.File.FileSize)
-}
+ b2, err := os.ReadFile(testPath)
+ if err != nil {
+ t.Fatalf("error reading file %s: %v", testPath, err)
+ }
-func TestManagerTestSuite(t *testing.T) {
- suite.Run(t, &ManagerTestSuite{})
+ if md5.Sum(b1) != md5.Sum(b2) {
+ t.Errorf("%s != %s", storagePath, testPath)
+ }
}
diff --git a/internal/media/png-stripper.go b/internal/media/png-stripper.go
deleted file mode 100644
index 09126f6a5..000000000
--- a/internal/media/png-stripper.go
+++ /dev/null
@@ -1,211 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// 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
-
-/*
- The code in this file is taken from the following source:
- https://github.com/google/wuffs/blob/414a011491ff513b86d8694c5d71800f3cb5a715/script/strip-png-ancillary-chunks.go
-
- It presents a workaround for this issue: https://github.com/golang/go/issues/43382
-
- The license for the copied code is reproduced below:
-
- Copyright 2021 The Wuffs Authors.
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- https://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-*/
-
-// strip-png-ancillary-chunks.go copies PNG data from stdin to stdout, removing
-// any ancillary chunks.
-//
-// Specification-compliant PNG decoders are required to honor critical chunks
-// but may ignore ancillary (non-critical) chunks. Stripping out ancillary
-// chunks before decoding should mean that different PNG decoders will agree on
-// the decoded output regardless of which ancillary chunk types they choose to
-// honor. Specifically, some PNG decoders may implement color and gamma
-// correction but not all do.
-//
-// This program will strip out all ancillary chunks, but it should be
-// straightforward to copy-paste-and-modify it to strip out only certain chunk
-// types (e.g. only "tRNS" transparency chunks).
-//
-// --------
-//
-// A PNG file consists of an 8-byte magic identifier and then a series of
-// chunks. Each chunk is:
-//
-// - a 4-byte uint32 payload length N.
-// - a 4-byte chunk type (e.g. "gAMA" for gamma correction metadata).
-// - an N-byte payload.
-// - a 4-byte CRC-32 checksum of the previous (N + 4) bytes, including the
-// chunk type but excluding the payload length.
-//
-// Chunk types consist of 4 ASCII letters. The upper-case / lower-case bit of
-// the first letter denote critical or ancillary chunks: "IDAT" and "PLTE" are
-// critical, "gAMA" and "tEXt" are ancillary. See
-// https://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-naming-conventions
-//
-// --------
-
-import (
- "encoding/binary"
- "io"
-)
-
-const (
- chunkTypeIHDR = 0x49484452
- chunkTypePLTE = 0x504C5445
- chunkTypeIDAT = 0x49444154
- chunkTypeIEND = 0x49454E44
- chunkTypeTRNS = 0x74524e53
-)
-
-func isNecessaryChunkType(chunkType uint32) bool {
- switch chunkType {
- case chunkTypeIHDR:
- return true
- case chunkTypePLTE:
- return true
- case chunkTypeIDAT:
- return true
- case chunkTypeIEND:
- return true
- case chunkTypeTRNS:
- return true
- }
- return false
-}
-
-// pngAncillaryChunkStripper wraps another io.Reader to strip ancillary chunks,
-// if the data is in the PNG file format. If the data isn't PNG, it is passed
-// through unmodified.
-type pngAncillaryChunkStripper struct {
- // Reader is the wrapped io.Reader.
- Reader io.Reader
-
- // stickyErr is the first error returned from the wrapped io.Reader.
- stickyErr error
-
- // buffer[rIndex:wIndex] holds data read from the wrapped io.Reader that
- // wasn't passed through yet.
- buffer [8]byte
- rIndex int
- wIndex int
-
- // pending and discard is the number of remaining bytes for (and whether to
- // discard or pass through) the current chunk-in-progress.
- pending int64
- discard bool
-
- // notPNG is set true if the data stream doesn't start with the 8-byte PNG
- // magic identifier. If true, the wrapped io.Reader's data (including the
- // first up-to-8 bytes) is passed through without modification.
- notPNG bool
-
- // seenMagic is whether we've seen the 8-byte PNG magic identifier.
- seenMagic bool
-}
-
-// Read implements io.Reader.
-func (r *pngAncillaryChunkStripper) Read(p []byte) (int, error) {
- for {
- // If the wrapped io.Reader returned a non-nil error, drain r.buffer
- // (what data we have) and return that error (if fully drained).
- if r.stickyErr != nil {
- n := copy(p, r.buffer[r.rIndex:r.wIndex])
- r.rIndex += n
- if r.rIndex < r.wIndex {
- return n, nil
- }
- return n, r.stickyErr
- }
-
- // Handle trivial requests, including draining our buffer.
- if len(p) == 0 {
- return 0, nil
- } else if r.rIndex < r.wIndex {
- n := copy(p, r.buffer[r.rIndex:r.wIndex])
- r.rIndex += n
- return n, nil
- }
-
- // From here onwards, our buffer is drained: r.rIndex == r.wIndex.
-
- // Handle non-PNG input.
- if r.notPNG {
- return r.Reader.Read(p)
- }
-
- // Continue processing any PNG chunk that's in progress, whether
- // discarding it or passing it through.
- for r.pending > 0 {
- if int64(len(p)) > r.pending {
- p = p[:r.pending]
- }
- n, err := r.Reader.Read(p)
- r.pending -= int64(n)
- r.stickyErr = err
- if r.discard {
- continue
- }
- return n, err
- }
-
- // We're either expecting the 8-byte PNG magic identifier or the 4-byte
- // PNG chunk length + 4-byte PNG chunk type. Either way, read 8 bytes.
- r.rIndex = 0
- r.wIndex, r.stickyErr = io.ReadFull(r.Reader, r.buffer[:8])
- if r.stickyErr != nil {
- // Undo io.ReadFull converting io.EOF to io.ErrUnexpectedEOF.
- if r.stickyErr == io.ErrUnexpectedEOF {
- r.stickyErr = io.EOF
- }
- continue
- }
-
- // Process those 8 bytes, either:
- // - a PNG chunk (if we've already seen the PNG magic identifier),
- // - the PNG magic identifier itself (if the input is a PNG) or
- // - something else (if it's not a PNG).
- //nolint:gocritic
- if r.seenMagic {
- // The number of pending bytes is equal to (N + 4) because of the 4
- // byte trailer, a checksum.
- r.pending = int64(binary.BigEndian.Uint32(r.buffer[:4])) + 4
- chunkType := binary.BigEndian.Uint32(r.buffer[4:])
- r.discard = !isNecessaryChunkType(chunkType)
- if r.discard {
- r.rIndex = r.wIndex
- }
- } else if string(r.buffer[:8]) == "\x89PNG\x0D\x0A\x1A\x0A" {
- r.seenMagic = true
- } else {
- r.notPNG = true
- }
- }
-}
diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go
index d61043523..cca456837 100644
--- a/internal/media/processingemoji.go
+++ b/internal/media/processingemoji.go
@@ -18,16 +18,10 @@
package media
import (
- "bytes"
"context"
- "io"
- "slices"
- "codeberg.org/gruf/go-bytesize"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-runners"
- "github.com/h2non/filetype"
- "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -125,19 +119,8 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
// full-size media attachment details.
//
// This will update p.emoji as it goes.
- if err = p.store(ctx); err != nil {
- return err
- }
-
- // Finish processing by reloading media into
- // memory to get dimension and generate a thumb.
- //
- // This will update p.emoji as it goes.
- if err = p.finish(ctx); err != nil {
- return err //nolint:revive
- }
-
- return nil
+ err = p.store(ctx)
+ return err
})
emoji = p.emoji
return
@@ -147,80 +130,66 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
// 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 {
- // Load media from provided data fun
- rc, sz, err := p.dataFn(ctx)
+ // Load media from data func.
+ rc, err := p.dataFn(ctx)
if err != nil {
return gtserror.Newf("error executing data function: %w", err)
}
+ var (
+ // predfine temporary media
+ // file path variables so we
+ // can remove them on error.
+ temppath string
+ staticpath string
+ )
+
defer func() {
- // Ensure data reader gets closed on return.
- if err := rc.Close(); err != nil {
- log.Errorf(ctx, "error closing data reader: %v", err)
+ if err := remove(temppath, staticpath); err != nil {
+ log.Errorf(ctx, "error(s) cleaning up files: %v", err)
}
}()
- var maxSize bytesize.Size
-
- if p.emoji.IsLocal() {
- // this is a local emoji upload
- maxSize = config.GetMediaEmojiLocalMaxSize()
- } else {
- // this is a remote incoming emoji
- maxSize = config.GetMediaEmojiRemoteMaxSize()
+ // Drain reader to tmp file
+ // (this reader handles close).
+ temppath, err = drainToTmp(rc)
+ if err != nil {
+ return gtserror.Newf("error draining data to tmp: %w", err)
}
- // Check that provided size isn't beyond max. We check beforehand
- // so that we don't attempt to stream the emoji into storage if not needed.
- if sz > 0 && sz > int64(maxSize) {
- sz := bytesize.Size(sz) // improves log readability
- return gtserror.Newf("given emoji size %s greater than max allowed %s", sz, maxSize)
+ // Pass input file through ffprobe to
+ // parse further metadata information.
+ result, err := ffprobe(ctx, temppath)
+ if err != nil {
+ return gtserror.Newf("error ffprobing data: %w", err)
}
- // Prepare to read bytes from
- // file header or magic number.
- fileSize := int(sz)
- hdrBuf := newHdrBuf(fileSize)
-
- // Read into buffer as much as possible.
- //
- // UnexpectedEOF means we couldn't read up to the
- // given size, but we may still have read something.
- //
- // EOF means we couldn't read anything at all.
- //
- // Any other error likely means the connection messed up.
- //
- // In other words, rather counterintuitively, we
- // can only proceed on no error or unexpected error!
- n, err := io.ReadFull(rc, hdrBuf)
- if err != nil {
- if err != io.ErrUnexpectedEOF {
- return gtserror.Newf("error reading first bytes of incoming media: %w", err)
- }
+ switch {
+ // No errors parsing data.
+ case result.Error == nil:
- // Initial file size was misreported, so we didn't read
- // fully into hdrBuf. Reslice it to the size we did read.
- hdrBuf = hdrBuf[:n]
- fileSize = n
- p.emoji.ImageFileSize = fileSize
- }
+ // Data type unhandleable by ffprobe.
+ case result.Error.Code == -1094995529:
+ log.Warn(ctx, "unsupported data type")
+ return nil
- // Parse file type info from header buffer.
- // This should only ever error if the buffer
- // is empty (ie., the attachment is 0 bytes).
- info, err := filetype.Match(hdrBuf)
- if err != nil {
- return gtserror.Newf("error parsing file type: %w", err)
+ default:
+ return gtserror.Newf("ffprobe error: %w", err)
}
- // Ensure supported emoji img type.
- if !slices.Contains(SupportedEmojiMIMETypes, info.MIME.Value) {
- return gtserror.Newf("unsupported emoji filetype: %s", info.Extension)
+ var ext string
+
+ // Set media type from ffprobe format data.
+ fileType, ext := result.Format.GetFileType()
+ if fileType != gtsmodel.FileTypeImage {
+ return gtserror.Newf("unsupported emoji filetype: %s (%s)", fileType, ext)
}
- // Recombine header bytes with remaining stream
- r := io.MultiReader(bytes.NewReader(hdrBuf), rc)
+ // Generate a static image from input emoji path.
+ staticpath, err = ffmpegGenerateStatic(ctx, temppath)
+ if err != nil {
+ return gtserror.Newf("error generating emoji static: %w", err)
+ }
var pathID string
if p.newPathID != "" {
@@ -244,91 +213,46 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
string(TypeEmoji),
string(SizeOriginal),
pathID,
- info.Extension,
+ ext,
)
- // File shouldn't already exist in storage at this point,
- // but we do a check as it's worth logging / cleaning up.
- if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImagePath); have {
- log.Warnf(ctx, "emoji already exists at: %s", p.emoji.ImagePath)
-
- // Attempt to remove existing emoji at storage path (might be broken / out-of-date)
- if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil {
- return gtserror.Newf("error removing emoji %s from storage: %v", p.emoji.ImagePath, err)
- }
- }
-
- // Write the final image reader stream to our storage.
- sz, err = p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r)
+ // Copy temporary file into storage at path.
+ filesz, err := p.mgr.state.Storage.PutFile(ctx,
+ p.emoji.ImagePath,
+ temppath,
+ )
if err != nil {
return gtserror.Newf("error writing emoji to storage: %w", err)
}
- // Perform final size check in case none was
- // given previously, or size was mis-reported.
- // (error here will later perform p.cleanup()).
- if sz > int64(maxSize) {
- sz := bytesize.Size(sz) // improves log readability
- return gtserror.Newf("written emoji size %s greater than max allowed %s", sz, maxSize)
+ // Copy static emoji file into storage at path.
+ staticsz, err := p.mgr.state.Storage.PutFile(ctx,
+ p.emoji.ImageStaticPath,
+ staticpath,
+ )
+ if err != nil {
+ return gtserror.Newf("error writing static to storage: %w", err)
}
+ // Set final determined file sizes.
+ p.emoji.ImageFileSize = int(filesz)
+ p.emoji.ImageStaticFileSize = int(staticsz)
+
// Fill in remaining emoji data now it's stored.
p.emoji.ImageURL = uris.URIForAttachment(
instanceAccID,
string(TypeEmoji),
string(SizeOriginal),
pathID,
- info.Extension,
+ ext,
)
- p.emoji.ImageContentType = info.MIME.Value
- p.emoji.ImageFileSize = int(sz)
- p.emoji.Cached = util.Ptr(true)
-
- return nil
-}
-
-func (p *ProcessingEmoji) finish(ctx context.Context) error {
- // Get a stream to the original file for further processing.
- rc, err := p.mgr.state.Storage.GetStream(ctx, p.emoji.ImagePath)
- if err != nil {
- return gtserror.Newf("error loading file from storage: %w", err)
- }
- defer rc.Close()
-
- // Decode the image from storage.
- staticImg, err := decodeImage(rc)
- if err != nil {
- return gtserror.Newf("error decoding image: %w", err)
- }
-
- // staticImg should be in-memory by
- // now so we're done with storage.
- if err := rc.Close(); err != nil {
- return gtserror.Newf("error closing file: %w", err)
- }
-
- // Static img shouldn't exist in storage at this point,
- // but we do a check as it's worth logging / cleaning up.
- if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImageStaticPath); have {
- log.Warnf(ctx, "static emoji already exists at: %s", p.emoji.ImageStaticPath)
-
- // Attempt to remove existing thumbnail (might be broken / out-of-date).
- if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath); err != nil {
- return gtserror.Newf("error removing static emoji %s from storage: %v", p.emoji.ImageStaticPath, err)
- }
- }
-
- // Create emoji PNG encoder stream.
- enc := staticImg.ToPNG()
- // Stream-encode the PNG static emoji image into our storage driver.
- sz, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImageStaticPath, enc)
- if err != nil {
- return gtserror.Newf("error stream-encoding static emoji to storage: %w", err)
- }
+ // Get mimetype for the file container
+ // type, falling back to generic data.
+ p.emoji.ImageContentType = getMimeType(ext)
- // Set final written thumb size.
- p.emoji.ImageStaticFileSize = int(sz)
+ // We can now consider this cached.
+ p.emoji.Cached = util.Ptr(true)
return nil
}
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
index 466c3443f..43e153a4d 100644
--- a/internal/media/processingmedia.go
+++ b/internal/media/processingmedia.go
@@ -18,18 +18,12 @@
package media
import (
- "bytes"
- "cmp"
"context"
- "image/jpeg"
- "io"
"time"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-runners"
- terminator "codeberg.org/superseriousbusiness/exif-terminator"
- "github.com/disintegration/imaging"
- "github.com/h2non/filetype"
+
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -145,19 +139,8 @@ func (p *ProcessingMedia) load(ctx context.Context) (
// full-size media attachment details.
//
// This will update p.media as it goes.
- if err = p.store(ctx); err != nil {
- return err
- }
-
- // Finish processing by reloading media into
- // memory to get dimension and generate a thumb.
- //
- // This will update p.media as it goes.
- if err = p.finish(ctx); err != nil {
- return err //nolint:revive
- }
-
- return nil
+ err = p.store(ctx)
+ return err
})
media = p.media
return
@@ -167,286 +150,244 @@ func (p *ProcessingMedia) load(ctx context.Context) (
// 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 {
- // Load media from provided data fun
- rc, sz, err := p.dataFn(ctx)
+ // Load media from data func.
+ rc, err := p.dataFn(ctx)
if err != nil {
return gtserror.Newf("error executing data function: %w", err)
}
+ var (
+ // predfine temporary media
+ // file path variables so we
+ // can remove them on error.
+ temppath string
+ thumbpath string
+ )
+
defer func() {
- // Ensure data reader gets closed on return.
- if err := rc.Close(); err != nil {
- log.Errorf(ctx, "error closing data reader: %v", err)
+ if err := remove(temppath, thumbpath); err != nil {
+ log.Errorf(ctx, "error(s) cleaning up files: %v", err)
}
}()
- // Assume we're given correct file
- // size, we can overwrite this later
- // once we know THE TRUTH.
- fileSize := int(sz)
- p.media.File.FileSize = fileSize
-
- // Prepare to read bytes from
- // file header or magic number.
- hdrBuf := newHdrBuf(fileSize)
-
- // Read into buffer as much as possible.
- //
- // UnexpectedEOF means we couldn't read up to the
- // given size, but we may still have read something.
- //
- // EOF means we couldn't read anything at all.
- //
- // Any other error likely means the connection messed up.
- //
- // In other words, rather counterintuitively, we
- // can only proceed on no error or unexpected error!
- n, err := io.ReadFull(rc, hdrBuf)
+ // Drain reader to tmp file
+ // (this reader handles close).
+ temppath, err = drainToTmp(rc)
if err != nil {
- if err != io.ErrUnexpectedEOF {
- return gtserror.Newf("error reading first bytes of incoming media: %w", err)
- }
-
- // Initial file size was misreported, so we didn't read
- // fully into hdrBuf. Reslice it to the size we did read.
- hdrBuf = hdrBuf[:n]
- fileSize = n
- p.media.File.FileSize = fileSize
+ return gtserror.Newf("error draining data to tmp: %w", err)
}
- // Parse file type info from header buffer.
- // This should only ever error if the buffer
- // is empty (ie., the attachment is 0 bytes).
- info, err := filetype.Match(hdrBuf)
+ // Pass input file through ffprobe to
+ // parse further metadata information.
+ result, err := ffprobe(ctx, temppath)
if err != nil {
- return gtserror.Newf("error parsing file type: %w", err)
+ return gtserror.Newf("error ffprobing data: %w", err)
}
- // Recombine header bytes with remaining stream
- r := io.MultiReader(bytes.NewReader(hdrBuf), rc)
-
- // Assume we'll put
- // this file in storage.
- store := true
+ switch {
+ // No errors parsing data.
+ case result.Error == nil:
- switch info.Extension {
- case "mp4":
- // No problem.
-
- case "gif":
- // No problem
-
- case "jpg", "jpeg", "png", "webp":
- if fileSize > 0 {
- // A file size was provided so we can clean
- // exif data from image as we're streaming it.
- r, err = terminator.Terminate(r, fileSize, info.Extension)
- if err != nil {
- return gtserror.Newf("error cleaning exif data: %w", err)
- }
- }
+ // Data type unhandleable by ffprobe.
+ case result.Error.Code == -1094995529:
+ log.Warn(ctx, "unsupported data type")
+ return nil
default:
- // The file is not a supported format that we can process, so we can't do much with it.
- log.Warnf(ctx, "unsupported media extension '%s'; not caching locally", info.Extension)
- store = false
+ return gtserror.Newf("ffprobe error: %w", err)
}
- // Fill in correct attachment
- // data now we've parsed it.
- p.media.URL = uris.URIForAttachment(
- p.media.AccountID,
- string(TypeAttachment),
- string(SizeOriginal),
- p.media.ID,
- info.Extension,
- )
-
- // Prefer discovered MIME, fallback to generic data stream.
- mime := cmp.Or(info.MIME.Value, "application/octet-stream")
- p.media.File.ContentType = mime
+ var ext string
- // Calculate final media attachment file path.
- p.media.File.Path = uris.StoragePathForAttachment(
- p.media.AccountID,
- string(TypeAttachment),
- string(SizeOriginal),
- p.media.ID,
- info.Extension,
- )
+ // Set the media type from ffprobe format data.
+ p.media.Type, ext = result.Format.GetFileType()
+ if p.media.Type == gtsmodel.FileTypeUnknown {
- // We should only try to store the file if it's
- // a format we can keep processing, otherwise be
- // a bit cheeky: don't store it and let users
- // click through to the remote server instead.
- if !store {
+ // Return early (deleting file)
+ // for unhandled file types.
return nil
}
- // File shouldn't already exist in storage at this point,
- // but we do a check as it's worth logging / cleaning up.
- if have, _ := p.mgr.state.Storage.Has(ctx, p.media.File.Path); have {
- log.Warnf(ctx, "media already exists at: %s", p.media.File.Path)
-
- // Attempt to remove existing media at storage path (might be broken / out-of-date)
- if err := p.mgr.state.Storage.Delete(ctx, p.media.File.Path); err != nil {
- return gtserror.Newf("error removing media %s from storage: %v", p.media.File.Path, err)
+ switch p.media.Type {
+ case gtsmodel.FileTypeImage:
+ // Pass file through ffmpeg clearing
+ // any excess metadata (e.g. EXIF).
+ if err := ffmpegClearMetadata(ctx,
+ temppath, ext,
+ ); err != nil {
+ return gtserror.Newf("error cleaning metadata: %w", err)
}
- }
-
- // Write the final reader stream to our storage driver.
- sz, err = p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r)
- if err != nil {
- return gtserror.Newf("error writing media to storage: %w", err)
- }
-
- // Set actual written size
- // as authoritative file size.
- p.media.File.FileSize = int(sz)
-
- // We can now consider this cached.
- p.media.Cached = util.Ptr(true)
-
- return nil
-}
-
-func (p *ProcessingMedia) finish(ctx context.Context) error {
- // Nothing else to do if
- // media was not cached.
- if !*p.media.Cached {
- return nil
- }
-
- // Get a stream to the original file for further processing.
- rc, err := p.mgr.state.Storage.GetStream(ctx, p.media.File.Path)
- if err != nil {
- return gtserror.Newf("error loading file from storage: %w", err)
- }
- defer rc.Close()
-
- // fullImg is the processed version of
- // the original (stripped + reoriented).
- var fullImg *gtsImage
- // Depending on the content type, we
- // can do various types of decoding.
- switch p.media.File.ContentType {
-
- // .jpeg, .gif, .webp image type
- case mimeImageJpeg, mimeImageGif, mimeImageWebp:
- fullImg, err = decodeImage(rc,
- imaging.AutoOrientation(true),
- )
+ // Extract image metadata from streams.
+ width, height, err := result.ImageMeta()
if err != nil {
- return gtserror.Newf("error decoding image: %w", err)
+ return err
}
-
- // Mark as no longer unknown type now
- // we know for sure we can decode it.
- p.media.Type = gtsmodel.FileTypeImage
-
- // .png image (requires ancillary chunk stripping)
- case mimeImagePng:
- fullImg, err = decodeImage(
- &pngAncillaryChunkStripper{Reader: rc},
- imaging.AutoOrientation(true),
+ p.media.FileMeta.Original.Width = width
+ p.media.FileMeta.Original.Height = height
+ p.media.FileMeta.Original.Size = (width * height)
+ p.media.FileMeta.Original.Aspect = float32(width) / float32(height)
+
+ // Determine thumbnail dimensions to use.
+ thumbWidth, thumbHeight := thumbSize(width, height)
+ p.media.FileMeta.Small.Width = thumbWidth
+ p.media.FileMeta.Small.Height = thumbHeight
+ p.media.FileMeta.Small.Size = (thumbWidth * thumbHeight)
+ p.media.FileMeta.Small.Aspect = float32(thumbWidth) / float32(thumbHeight)
+
+ // Generate a thumbnail image from input image path.
+ thumbpath, err = ffmpegGenerateThumb(ctx, temppath,
+ thumbWidth,
+ thumbHeight,
)
if err != nil {
- return gtserror.Newf("error decoding image: %w", err)
+ return gtserror.Newf("error generating image thumb: %w", err)
}
- // Mark as no longer unknown type now
- // we know for sure we can decode it.
- p.media.Type = gtsmodel.FileTypeImage
+ case gtsmodel.FileTypeVideo:
+ // Pass file through ffmpeg clearing
+ // any excess metadata (e.g. EXIF).
+ if err := ffmpegClearMetadata(ctx,
+ temppath, ext,
+ ); err != nil {
+ return gtserror.Newf("error cleaning metadata: %w", err)
+ }
- // .mp4 video type
- case mimeVideoMp4:
- video, err := decodeVideoFrame(rc)
+ // Extract video metadata we can from streams.
+ width, height, framerate, err := result.VideoMeta()
if err != nil {
- return gtserror.Newf("error decoding video: %w", err)
+ return err
+ }
+ p.media.FileMeta.Original.Width = width
+ p.media.FileMeta.Original.Height = height
+ p.media.FileMeta.Original.Size = (width * height)
+ p.media.FileMeta.Original.Aspect = float32(width) / float32(height)
+ p.media.FileMeta.Original.Framerate = &framerate
+
+ // Extract total duration from format.
+ duration := result.Format.GetDuration()
+ p.media.FileMeta.Original.Duration = &duration
+
+ // Extract total bitrate from format.
+ bitrate := result.Format.GetBitRate()
+ p.media.FileMeta.Original.Bitrate = &bitrate
+
+ // Determine thumbnail dimensions to use.
+ thumbWidth, thumbHeight := thumbSize(width, height)
+ p.media.FileMeta.Small.Width = thumbWidth
+ p.media.FileMeta.Small.Height = thumbHeight
+ p.media.FileMeta.Small.Size = (thumbWidth * thumbHeight)
+ p.media.FileMeta.Small.Aspect = float32(thumbWidth) / float32(thumbHeight)
+
+ // Extract a thumbnail frame from input video path.
+ thumbpath, err = ffmpegGenerateThumb(ctx, temppath,
+ thumbWidth,
+ thumbHeight,
+ )
+ if err != nil {
+ return gtserror.Newf("error extracting video frame: %w", err)
}
- // Set video frame as image.
- fullImg = video.frame
-
- // Set video metadata in attachment info.
- p.media.FileMeta.Original.Duration = &video.duration
- p.media.FileMeta.Original.Framerate = &video.framerate
- p.media.FileMeta.Original.Bitrate = &video.bitrate
+ case gtsmodel.FileTypeAudio:
+ // Extract total duration from format.
+ duration := result.Format.GetDuration()
+ p.media.FileMeta.Original.Duration = &duration
+
+ // Extract total bitrate from format.
+ bitrate := result.Format.GetBitRate()
+ p.media.FileMeta.Original.Bitrate = &bitrate
+
+ // Extract image metadata from streams (if any),
+ // this will only exist for embedded album art.
+ width, height, _ := result.ImageMeta()
+ if width > 0 && height > 0 {
+
+ // Determine thumbnail dimensions to use.
+ thumbWidth, thumbHeight := thumbSize(width, height)
+ p.media.FileMeta.Small.Width = thumbWidth
+ p.media.FileMeta.Small.Height = thumbHeight
+ p.media.FileMeta.Small.Size = (thumbWidth * thumbHeight)
+ p.media.FileMeta.Small.Aspect = float32(thumbWidth) / float32(thumbHeight)
+
+ // Generate a thumbnail image from input image path.
+ thumbpath, err = ffmpegGenerateThumb(ctx, temppath,
+ thumbWidth,
+ thumbHeight,
+ )
+ if err != nil {
+ return gtserror.Newf("error generating image thumb: %w", err)
+ }
+ }
- // Mark as no longer unknown type now
- // we know for sure we can decode it.
- p.media.Type = gtsmodel.FileTypeVideo
+ default:
+ log.Warnf(ctx, "unsupported type: %s (%s)", p.media.Type, result.Format.FormatName)
+ return nil
}
- // fullImg should be in-memory by
- // now so we're done with storage.
- if err := rc.Close(); err != nil {
- return gtserror.Newf("error closing file: %w", err)
+ // Calculate final media attachment file path.
+ p.media.File.Path = uris.StoragePathForAttachment(
+ p.media.AccountID,
+ string(TypeAttachment),
+ string(SizeOriginal),
+ p.media.ID,
+ ext,
+ )
+
+ // Copy temporary file into storage at path.
+ filesz, err := p.mgr.state.Storage.PutFile(ctx,
+ p.media.File.Path,
+ temppath,
+ )
+ if err != nil {
+ return gtserror.Newf("error writing media to storage: %w", err)
}
- // Set full-size dimensions in attachment info.
- p.media.FileMeta.Original.Width = fullImg.Width()
- p.media.FileMeta.Original.Height = fullImg.Height()
- p.media.FileMeta.Original.Size = fullImg.Size()
- p.media.FileMeta.Original.Aspect = fullImg.AspectRatio()
+ // Set final determined file size.
+ p.media.File.FileSize = int(filesz)
- // Get smaller thumbnail image
- thumbImg := fullImg.Thumbnail()
+ if thumbpath != "" {
+ // Note that neither thumbnail storage
+ // nor a blurhash are needed for audio.
- // Garbage collector, you may
- // now take our large son.
- fullImg = nil
+ if p.media.Blurhash == "" {
+ // Generate blurhash (if not already) from thumbnail.
+ p.media.Blurhash, err = generateBlurhash(thumbpath)
+ if err != nil {
+ return gtserror.Newf("error generating thumb blurhash: %w", err)
+ }
+ }
- // Only generate blurhash
- // from thumb if necessary.
- if p.media.Blurhash == "" {
- hash, err := thumbImg.Blurhash()
+ // Copy thumbnail file into storage at path.
+ thumbsz, err := p.mgr.state.Storage.PutFile(ctx,
+ p.media.Thumbnail.Path,
+ thumbpath,
+ )
if err != nil {
- return gtserror.Newf("error generating blurhash: %w", err)
+ return gtserror.Newf("error writing thumb to storage: %w", err)
}
- // Set the attachment blurhash.
- p.media.Blurhash = hash
+ // Set final determined thumbnail size.
+ p.media.Thumbnail.FileSize = int(thumbsz)
}
- // Thumbnail shouldn't exist in storage at this point,
- // but we do a check as it's worth logging / cleaning up.
- if have, _ := p.mgr.state.Storage.Has(ctx, p.media.Thumbnail.Path); have {
- log.Warnf(ctx, "thumbnail already exists at: %s", p.media.Thumbnail.Path)
-
- // Attempt to remove existing thumbnail (might be broken / out-of-date).
- if err := p.mgr.state.Storage.Delete(ctx, p.media.Thumbnail.Path); err != nil {
- return gtserror.Newf("error removing thumbnail %s from storage: %v", p.media.Thumbnail.Path, err)
- }
- }
-
- // Create a thumbnail JPEG encoder stream.
- enc := thumbImg.ToJPEG(&jpeg.Options{
-
- // Good enough for
- // a thumbnail.
- Quality: 70,
- })
-
- // Stream-encode the JPEG thumbnail image into our storage driver.
- sz, err := p.mgr.state.Storage.PutStream(ctx, p.media.Thumbnail.Path, enc)
- if err != nil {
- return gtserror.Newf("error stream-encoding thumbnail to storage: %w", err)
- }
+ // Fill in correct attachment
+ // data now we've parsed it.
+ p.media.URL = uris.URIForAttachment(
+ p.media.AccountID,
+ string(TypeAttachment),
+ string(SizeOriginal),
+ p.media.ID,
+ ext,
+ )
- // Set final written thumb size.
- p.media.Thumbnail.FileSize = int(sz)
+ // Get mimetype for the file container
+ // type, falling back to generic data.
+ p.media.File.ContentType = getMimeType(ext)
- // Set thumbnail dimensions in attachment info.
- p.media.FileMeta.Small = gtsmodel.Small{
- Width: thumbImg.Width(),
- Height: thumbImg.Height(),
- Size: thumbImg.Size(),
- Aspect: thumbImg.AspectRatio(),
- }
+ // We can now consider this cached.
+ p.media.Cached = util.Ptr(true)
- // Finally set the attachment as processed.
+ // Finally set the attachment as finished processing.
p.media.Processing = gtsmodel.ProcessingStatusProcessed
return nil
diff --git a/internal/media/refetch.go b/internal/media/refetch.go
index d02f14872..e5b91d56f 100644
--- a/internal/media/refetch.go
+++ b/internal/media/refetch.go
@@ -24,12 +24,13 @@ import (
"io"
"net/url"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
-type DereferenceMedia func(ctx context.Context, iri *url.URL) (io.ReadCloser, int64, error)
+type DereferenceMedia func(ctx context.Context, iri *url.URL, maxsz int64) (io.ReadCloser, error)
// RefetchEmojis iterates through remote emojis (for the given domain, or all if domain is empty string).
//
@@ -48,6 +49,9 @@ func (m *Manager) RefetchEmojis(ctx context.Context, domain string, dereferenceM
refetchIDs []string
)
+ // Get max supported remote emoji media size.
+ maxsz := config.GetMediaEmojiRemoteMaxSize()
+
// page through emojis 20 at a time, looking for those with missing images
for {
// Fetch next block of emojis from database
@@ -107,8 +111,8 @@ func (m *Manager) RefetchEmojis(ctx context.Context, domain string, dereferenceM
continue
}
- dataFunc := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) {
- return dereferenceMedia(ctx, emojiImageIRI)
+ dataFunc := func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return dereferenceMedia(ctx, emojiImageIRI, int64(maxsz))
}
processingEmoji, err := m.RefreshEmoji(ctx, emoji, dataFunc, AdditionalEmojiInfo{
diff --git a/internal/media/test/birdnest-processed.mp4 b/internal/media/test/birdnest-processed.mp4
index 2ecc075cd..ed9d73a7d 100644
--- a/internal/media/test/birdnest-processed.mp4
+++ b/internal/media/test/birdnest-processed.mp4
Binary files differ
diff --git a/internal/media/test/birdnest-thumbnail.jpg b/internal/media/test/birdnest-thumbnail.jpg
index b20de32a3..d9d4fc0c9 100644
--- a/internal/media/test/birdnest-thumbnail.jpg
+++ b/internal/media/test/birdnest-thumbnail.jpg
Binary files differ
diff --git a/internal/media/test/gts_pixellated-static.png b/internal/media/test/gts_pixellated-static.png
index c6dcb0f4a..530b628bf 100644
--- a/internal/media/test/gts_pixellated-static.png
+++ b/internal/media/test/gts_pixellated-static.png
Binary files differ
diff --git a/internal/media/test/longer-mp4-processed.mp4 b/internal/media/test/longer-mp4-processed.mp4
index cfb596612..d792dc3c5 100644
--- a/internal/media/test/longer-mp4-processed.mp4
+++ b/internal/media/test/longer-mp4-processed.mp4
Binary files differ
diff --git a/internal/media/test/longer-mp4-thumbnail.jpg b/internal/media/test/longer-mp4-thumbnail.jpg
index 076db8251..1700b0cb1 100644
--- a/internal/media/test/longer-mp4-thumbnail.jpg
+++ b/internal/media/test/longer-mp4-thumbnail.jpg
Binary files differ
diff --git a/internal/media/test/nb-flag-static.png b/internal/media/test/nb-flag-static.png
index 399eae5e5..384ee53f7 100644
--- a/internal/media/test/nb-flag-static.png
+++ b/internal/media/test/nb-flag-static.png
Binary files differ
diff --git a/internal/media/test/rainbow-static.png b/internal/media/test/rainbow-static.png
index 79ed5c03a..f762a0470 100644
--- a/internal/media/test/rainbow-static.png
+++ b/internal/media/test/rainbow-static.png
Binary files differ
diff --git a/internal/media/test/test-jpeg-thumbnail.jpg b/internal/media/test/test-jpeg-thumbnail.jpg
index c11569fe6..e2251afec 100644
--- a/internal/media/test/test-jpeg-thumbnail.jpg
+++ b/internal/media/test/test-jpeg-thumbnail.jpg
Binary files differ
diff --git a/internal/media/test/test-mp4-processed.mp4 b/internal/media/test/test-mp4-processed.mp4
index f78f51de6..2bd33ba48 100644
--- a/internal/media/test/test-mp4-processed.mp4
+++ b/internal/media/test/test-mp4-processed.mp4
Binary files differ
diff --git a/internal/media/test/test-mp4-thumbnail.jpg b/internal/media/test/test-mp4-thumbnail.jpg
index 6d33c1b78..35dc7b619 100644
--- a/internal/media/test/test-mp4-thumbnail.jpg
+++ b/internal/media/test/test-mp4-thumbnail.jpg
Binary files differ
diff --git a/internal/media/test/test-opus-original.opus b/internal/media/test/test-opus-original.opus
new file mode 100644
index 000000000..1dc6f28fa
--- /dev/null
+++ b/internal/media/test/test-opus-original.opus
Binary files differ
diff --git a/internal/media/test/test-opus-processed.opus b/internal/media/test/test-opus-processed.opus
new file mode 100644
index 000000000..1dc6f28fa
--- /dev/null
+++ b/internal/media/test/test-opus-processed.opus
Binary files differ
diff --git a/internal/media/test/test-png-alphachannel-processed.png b/internal/media/test/test-png-alphachannel-processed.png
index 9d05d45ef..cb3857e9c 100644
--- a/internal/media/test/test-png-alphachannel-processed.png
+++ b/internal/media/test/test-png-alphachannel-processed.png
Binary files differ
diff --git a/internal/media/test/test-png-alphachannel-thumbnail.jpg b/internal/media/test/test-png-alphachannel-thumbnail.jpg
index 8342157be..f98e69800 100644
--- a/internal/media/test/test-png-alphachannel-thumbnail.jpg
+++ b/internal/media/test/test-png-alphachannel-thumbnail.jpg
Binary files differ
diff --git a/internal/media/test/test-png-noalphachannel-thumbnail.jpg b/internal/media/test/test-png-noalphachannel-thumbnail.jpg
index 8342157be..7e54ebae7 100644
--- a/internal/media/test/test-png-noalphachannel-thumbnail.jpg
+++ b/internal/media/test/test-png-noalphachannel-thumbnail.jpg
Binary files differ
diff --git a/internal/media/types.go b/internal/media/types.go
index cea026b98..2d19b84cc 100644
--- a/internal/media/types.go
+++ b/internal/media/types.go
@@ -144,4 +144,4 @@ type AdditionalEmojiInfo struct {
}
// DataFunc represents a function used to retrieve the raw bytes of a piece of media.
-type DataFunc func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error)
+type DataFunc func(ctx context.Context) (reader io.ReadCloser, err error)
diff --git a/internal/media/util.go b/internal/media/util.go
index 296bdb883..4a31c9f8e 100644
--- a/internal/media/util.go
+++ b/internal/media/util.go
@@ -17,25 +17,161 @@
package media
-// newHdrBuf returns a buffer of suitable size to
-// read bytes from a file header or magic number.
-//
-// File header is *USUALLY* 261 bytes at the start
-// of a file; magic number can be much less than
-// that (just a few bytes).
-//
-// To cover both cases, this function returns a buffer
-// suitable for whichever is smallest: the first 261
-// bytes of the file, or the whole file.
-//
-// See:
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "io"
+ "os"
+
+ "codeberg.org/gruf/go-bytesize"
+ "codeberg.org/gruf/go-iotools"
+ "codeberg.org/gruf/go-mimetypes"
+ "github.com/buckket/go-blurhash"
+ "github.com/disintegration/imaging"
+)
+
+// thumbSize returns the dimensions to use for an input
+// image of given width / height, for its outgoing thumbnail.
+// This maintains the original image aspect ratio.
+func thumbSize(width, height int) (int, int) {
+ const (
+ maxThumbWidth = 512
+ maxThumbHeight = 512
+ )
+ switch {
+ // Simplest case, within bounds!
+ case width < maxThumbWidth &&
+ height < maxThumbHeight:
+ return width, height
+
+ // Width is larger side.
+ case width > height:
+ p := float32(width) / float32(maxThumbWidth)
+ return maxThumbWidth, int(float32(height) / p)
+
+ // Height is larger side.
+ case height > width:
+ p := float32(height) / float32(maxThumbHeight)
+ return int(float32(width) / p), maxThumbHeight
+
+ // Square.
+ default:
+ return maxThumbWidth, maxThumbHeight
+ }
+}
+
+// jpegDecode decodes the JPEG at filepath into parsed image.Image.
+func jpegDecode(filepath string) (image.Image, error) {
+ // Open the file at given path.
+ file, err := os.Open(filepath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Decode image from file.
+ img, err := jpeg.Decode(file)
+
+ // Done with file.
+ _ = file.Close()
+
+ return img, err
+}
+
+// generateBlurhash generates a blurhash for JPEG at filepath.
+func generateBlurhash(filepath string) (string, error) {
+ // Decode JPEG file at given path.
+ img, err := jpegDecode(filepath)
+ if err != nil {
+ return "", err
+ }
+
+ // for generating blurhashes, it's more cost effective to
+ // lose detail since it's blurry, so make a tiny version.
+ tiny := imaging.Resize(img, 64, 64, imaging.NearestNeighbor)
+
+ // Drop the larger image
+ // ref as soon as possible
+ // to allow GC to claim.
+ img = nil //nolint
+
+ // Generate blurhash for thumbnail.
+ return blurhash.Encode(4, 3, tiny)
+}
+
+// getMimeType returns a suitable mimetype for file extension.
+func getMimeType(ext string) string {
+ const defaultType = "application/octet-stream"
+ return cmp.Or(mimetypes.MimeTypes[ext], defaultType)
+}
+
+// drainToTmp drains data from given reader into a new temp file
+// and closes it, returning the path of the resulting temp file.
//
-// - https://en.wikipedia.org/wiki/File_format#File_header
-// - https://github.com/h2non/filetype.
-func newHdrBuf(fileSize int) []byte {
- bufSize := 261
- if fileSize > 0 && fileSize < bufSize {
- bufSize = fileSize
- }
- return make([]byte, bufSize)
+// Note that this function specifically makes attempts to unwrap the
+// io.ReadCloser as much as it can to underlying type, to maximise
+// chance that Linux's sendfile syscall can be utilised for optimal
+// draining of data source to temporary file storage.
+func drainToTmp(rc io.ReadCloser) (string, error) {
+ tmp, err := os.CreateTemp(os.TempDir(), "gotosocial-*")
+ if err != nil {
+ return "", err
+ }
+
+ // Close readers
+ // on func return.
+ defer tmp.Close()
+ defer rc.Close()
+
+ // Extract file path.
+ path := tmp.Name()
+
+ // Limited reader (if any).
+ var lr *io.LimitedReader
+ var limit int64
+
+ // Reader type to use
+ // for draining to tmp.
+ rd := (io.Reader)(rc)
+
+ // Check if reader is actually wrapped,
+ // (as our http client wraps close func).
+ rct, ok := rc.(*iotools.ReadCloserType)
+ if ok {
+
+ // Get unwrapped.
+ rd = rct.Reader
+
+ // Extract limited reader if wrapped.
+ lr, limit = iotools.GetReaderLimit(rd)
+ }
+
+ // Drain reader into tmp.
+ _, err = tmp.ReadFrom(rd)
+ if err != nil {
+ return path, err
+ }
+
+ // Check to see if limit was reached,
+ // (produces more useful error messages).
+ if lr != nil && !iotools.AtEOF(lr.R) {
+ return path, fmt.Errorf("reached read limit %s", bytesize.Size(limit))
+ }
+
+ return path, nil
+}
+
+// remove only removes paths if not-empty.
+func remove(paths ...string) error {
+ var errs []error
+ for _, path := range paths {
+ if path != "" {
+ if err := os.Remove(path); err != nil {
+ errs = append(errs, fmt.Errorf("error removing %s: %w", path, err))
+ }
+ }
+ }
+ return errors.Join(errs...)
}
diff --git a/internal/media/video.go b/internal/media/video.go
deleted file mode 100644
index 5068be636..000000000
--- a/internal/media/video.go
+++ /dev/null
@@ -1,141 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// 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 (
- "fmt"
- "io"
-
- "github.com/abema/go-mp4"
- "github.com/superseriousbusiness/gotosocial/internal/iotools"
- "github.com/superseriousbusiness/gotosocial/internal/log"
-)
-
-type gtsVideo struct {
- frame *gtsImage
- duration float32 // in seconds
- bitrate uint64
- framerate float32
-}
-
-// decodeVideoFrame decodes and returns an image from a single frame in the given video stream.
-// (note: currently this only returns a blank image resized to fit video dimensions).
-func decodeVideoFrame(r io.Reader) (*gtsVideo, error) {
- // Check if video stream supports
- // seeking, usually when *os.File.
- rsc, ok := r.(io.ReadSeekCloser)
- if !ok {
- var err error
-
- // Store stream to temporary location
- // in order that we can get seek-reads.
- rsc, err = iotools.TempFileSeeker(r)
- if err != nil {
- return nil, fmt.Errorf("error creating temp file seeker: %w", err)
- }
-
- defer func() {
- // Ensure temp. read seeker closed.
- if err := rsc.Close(); err != nil {
- log.Errorf(nil, "error closing temp file seeker: %s", err)
- }
- }()
- }
-
- // probe the video file to extract useful metadata from it; for methodology, see:
- // https://github.com/abema/go-mp4/blob/7d8e5a7c5e644e0394261b0cf72fef79ce246d31/mp4tool/probe/probe.go#L85-L154
- info, err := mp4.Probe(rsc)
- if err != nil {
- return nil, fmt.Errorf("error during mp4 probe: %w", err)
- }
-
- var (
- width int
- height int
- videoBitrate uint64
- audioBitrate uint64
- video gtsVideo
- )
-
- for _, tr := range info.Tracks {
- if tr.AVC == nil {
- // audio track
- if br := tr.Samples.GetBitrate(tr.Timescale); br > audioBitrate {
- audioBitrate = br
- } else if br := info.Segments.GetBitrate(tr.TrackID, tr.Timescale); br > audioBitrate {
- audioBitrate = br
- }
-
- if d := float64(tr.Duration) / float64(tr.Timescale); d > float64(video.duration) {
- video.duration = float32(d)
- }
- continue
- }
-
- // video track
- if w := int(tr.AVC.Width); w > width {
- width = w
- }
-
- if h := int(tr.AVC.Height); h > height {
- height = h
- }
-
- if br := tr.Samples.GetBitrate(tr.Timescale); br > videoBitrate {
- videoBitrate = br
- } else if br := info.Segments.GetBitrate(tr.TrackID, tr.Timescale); br > videoBitrate {
- videoBitrate = br
- }
-
- if d := float64(tr.Duration) / float64(tr.Timescale); d > float64(video.duration) {
- video.framerate = float32(len(tr.Samples)) / float32(d)
- video.duration = float32(d)
- }
- }
-
- // overall bitrate should be audio + video combined
- // (since they're both playing at the same time)
- video.bitrate = audioBitrate + videoBitrate
-
- // Check for empty video metadata.
- var empty []string
- if width == 0 {
- empty = append(empty, "width")
- }
- if height == 0 {
- empty = append(empty, "height")
- }
- if video.duration == 0 {
- empty = append(empty, "duration")
- }
- if video.framerate == 0 {
- empty = append(empty, "framerate")
- }
- if video.bitrate == 0 {
- empty = append(empty, "bitrate")
- }
- if len(empty) > 0 {
- return nil, fmt.Errorf("error determining video metadata: %v", empty)
- }
-
- // Create new empty "frame" image.
- // TODO: decode frame from video file.
- video.frame = blankImage(width, height)
-
- return &video, nil
-}
diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go
index ba9360c36..fda871bd5 100644
--- a/internal/processing/account/update.go
+++ b/internal/processing/account/update.go
@@ -24,7 +24,7 @@ import (
"io"
"mime/multipart"
- "codeberg.org/gruf/go-bytesize"
+ "codeberg.org/gruf/go-iotools"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
@@ -365,21 +365,31 @@ func (p *Processor) UpdateAvatar(
*gtsmodel.MediaAttachment,
gtserror.WithCode,
) {
- max := config.GetMediaImageMaxSize()
- if sz := bytesize.Size(avatar.Size); sz > max {
- text := fmt.Sprintf("size %s exceeds max media size %s", sz, max)
+ // Get maximum supported local media size.
+ maxsz := config.GetMediaLocalMaxSize()
+
+ // Ensure media within size bounds.
+ if avatar.Size > int64(maxsz) {
+ text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- f, err := avatar.Open()
- return f, avatar.Size, err
+ // Open multipart file reader.
+ mpfile, err := avatar.Open()
+ if err != nil {
+ err := gtserror.Newf("error opening multipart file: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
}
+ // Wrap the multipart file reader to ensure is limited to max.
+ rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
+
// Write to instance storage.
return p.c.StoreLocalMedia(ctx,
account.ID,
- data,
+ func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return rc, nil
+ },
media.AdditionalMediaInfo{
Avatar: util.Ptr(true),
Description: description,
@@ -400,21 +410,31 @@ func (p *Processor) UpdateHeader(
*gtsmodel.MediaAttachment,
gtserror.WithCode,
) {
- max := config.GetMediaImageMaxSize()
- if sz := bytesize.Size(header.Size); sz > max {
- text := fmt.Sprintf("size %s exceeds max media size %s", sz, max)
+ // Get maximum supported local media size.
+ maxsz := config.GetMediaLocalMaxSize()
+
+ // Ensure media within size bounds.
+ if header.Size > int64(maxsz) {
+ text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- f, err := header.Open()
- return f, header.Size, err
+ // Open multipart file reader.
+ mpfile, err := header.Open()
+ if err != nil {
+ err := gtserror.Newf("error opening multipart file: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
}
+ // Wrap the multipart file reader to ensure is limited to max.
+ rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
+
// Write to instance storage.
return p.c.StoreLocalMedia(ctx,
account.ID,
- data,
+ func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return rc, nil
+ },
media.AdditionalMediaInfo{
Header: util.Ptr(true),
Description: description,
diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go
index c023fabd8..cf5bacef8 100644
--- a/internal/processing/admin/emoji.go
+++ b/internal/processing/admin/emoji.go
@@ -25,7 +25,10 @@ import (
"mime/multipart"
"strings"
+ "codeberg.org/gruf/go-bytesize"
+ "codeberg.org/gruf/go-iotools"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -41,10 +44,26 @@ func (p *Processor) EmojiCreate(
form *apimodel.EmojiCreateRequest,
) (*apimodel.Emoji, gtserror.WithCode) {
- // Simply read provided form data for emoji data source.
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- f, err := form.Image.Open()
- return f, form.Image.Size, err
+ // Get maximum supported local emoji size.
+ maxsz := config.GetMediaEmojiLocalMaxSize()
+
+ // Ensure media within size bounds.
+ if form.Image.Size > int64(maxsz) {
+ text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Open multipart file reader.
+ mpfile, err := form.Image.Open()
+ if err != nil {
+ err := gtserror.Newf("error opening multipart file: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Wrap the multipart file reader to ensure is limited to max.
+ rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
+ data := func(context.Context) (io.ReadCloser, error) {
+ return rc, nil
}
// Attempt to create the new local emoji.
@@ -285,14 +304,23 @@ func (p *Processor) emojiUpdateCopy(
return nil, gtserror.NewErrorNotFound(err)
}
+ // Get maximum supported local emoji size.
+ maxsz := config.GetMediaEmojiLocalMaxSize()
+
+ // Ensure target emoji image within size bounds.
+ if bytesize.Size(target.ImageFileSize) > maxsz {
+ text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
// Data function for copying just streams media
// out of storage into an additional location.
//
// This means that data for the copy persists even
// if the remote copied emoji gets deleted at some point.
- data := func(ctx context.Context) (io.ReadCloser, int64, error) {
+ data := func(ctx context.Context) (io.ReadCloser, error) {
rc, err := p.state.Storage.GetStream(ctx, target.ImagePath)
- return rc, int64(target.ImageFileSize), err
+ return rc, err
}
// Attempt to create the new local emoji.
@@ -413,10 +441,26 @@ func (p *Processor) emojiUpdateModify(
// Updating image and maybe categoryID.
// We can do both at the same time :)
- // Simply read provided form data for emoji data source.
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- f, err := image.Open()
- return f, image.Size, err
+ // Get maximum supported local emoji size.
+ maxsz := config.GetMediaEmojiLocalMaxSize()
+
+ // Ensure media within size bounds.
+ if image.Size > int64(maxsz) {
+ text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Open multipart file reader.
+ mpfile, err := image.Open()
+ if err != nil {
+ err := gtserror.Newf("error opening multipart file: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Wrap the multipart file reader to ensure is limited to max.
+ rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
+ data := func(context.Context) (io.ReadCloser, error) {
+ return rc, nil
}
// Prepare emoji model for recache from new data.
diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go
index edbcbe349..9cd68d88b 100644
--- a/internal/processing/admin/media.go
+++ b/internal/processing/admin/media.go
@@ -21,6 +21,7 @@ import (
"context"
"fmt"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -35,8 +36,9 @@ func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmode
}
go func() {
+ ctx := gtscontext.WithValues(context.Background(), ctx)
log.Info(ctx, "starting emoji refetch")
- refetched, err := p.media.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia)
+ refetched, err := p.media.RefetchEmojis(ctx, domain, transport.DereferenceMedia)
if err != nil {
log.Errorf(ctx, "error refetching emojis: %s", err)
} else {
diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go
index 0dbe997de..b3a7d6052 100644
--- a/internal/processing/media/create.go
+++ b/internal/processing/media/create.go
@@ -19,10 +19,13 @@ package media
import (
"context"
+ "errors"
"fmt"
"io"
+ "codeberg.org/gruf/go-iotools"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
@@ -30,21 +33,39 @@ import (
// Create creates a new media attachment belonging to the given account, using the request form.
func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) {
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- f, err := form.File.Open()
- return f, form.File.Size, err
+
+ // Get maximum supported local media size.
+ maxsz := config.GetMediaLocalMaxSize()
+
+ // Ensure media within size bounds.
+ if form.File.Size > int64(maxsz) {
+ text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
+ // Parse focus details from API form input.
focusX, focusY, err := parseFocus(form.Focus)
if err != nil {
- err := fmt.Errorf("could not parse focus value %s: %s", form.Focus, err)
- return nil, gtserror.NewErrorBadRequest(err, err.Error())
+ text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Open multipart file reader.
+ mpfile, err := form.File.Open()
+ if err != nil {
+ err := gtserror.Newf("error opening multipart file: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
}
+ // Wrap the multipart file reader to ensure is limited to max.
+ rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
+
// Create local media and write to instance storage.
attachment, errWithCode := p.c.StoreLocalMedia(ctx,
account.ID,
- data,
+ func(ctx context.Context) (reader io.ReadCloser, err error) {
+ return rc, nil
+ },
media.AdditionalMediaInfo{
Description: &form.Description,
FocusX: &focusX,
diff --git a/internal/processing/media/getfile_test.go b/internal/processing/media/getfile_test.go
index f0517b339..34f5d99a2 100644
--- a/internal/processing/media/getfile_test.go
+++ b/internal/processing/media/getfile_test.go
@@ -18,7 +18,6 @@
package media_test
import (
- "bytes"
"context"
"io"
"path"
@@ -87,9 +86,9 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncached() {
MediaSize: string(media.SizeOriginal),
FileName: fileName,
})
-
suite.NoError(errWithCode)
suite.NotNil(content)
+
b, err := io.ReadAll(content.Content)
suite.NoError(err)
suite.NoError(content.Content.Close())
@@ -111,7 +110,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncached() {
suite.True(*dbAttachment.Cached)
// the file should be back in storage at the same path as before
- refreshedBytes, err := suite.storage.Get(ctx, testAttachment.File.Path)
+ refreshedBytes, err := suite.storage.Get(ctx, dbAttachment.File.Path)
suite.NoError(err)
suite.Equal(suite.testRemoteAttachments[testAttachment.RemoteURL].Data, refreshedBytes)
}
@@ -139,32 +138,26 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncachedInterrupted() {
MediaSize: string(media.SizeOriginal),
FileName: fileName,
})
-
suite.NoError(errWithCode)
suite.NotNil(content)
- // only read the first kilobyte and then stop
- b := make([]byte, 0, 1024)
- if !testrig.WaitFor(func() bool {
- read, err := io.CopyN(bytes.NewBuffer(b), content.Content, 1024)
- return err == nil && read == 1024
- }) {
- suite.FailNow("timed out trying to read first 1024 bytes")
- }
+ _, err = io.CopyN(io.Discard, content.Content, 1024)
+ suite.NoError(err)
- // close the reader
- suite.NoError(content.Content.Close())
+ err = content.Content.Close()
+ suite.NoError(err)
// the attachment should still be updated in the database even though the caller hung up
+ var dbAttachment *gtsmodel.MediaAttachment
if !testrig.WaitFor(func() bool {
- dbAttachment, _ := suite.db.GetAttachmentByID(ctx, testAttachment.ID)
+ dbAttachment, _ = suite.db.GetAttachmentByID(ctx, testAttachment.ID)
return *dbAttachment.Cached
}) {
suite.FailNow("timed out waiting for attachment to be updated")
}
// the file should be back in storage at the same path as before
- refreshedBytes, err := suite.storage.Get(ctx, testAttachment.File.Path)
+ refreshedBytes, err := suite.storage.Get(ctx, dbAttachment.File.Path)
suite.NoError(err)
suite.Equal(suite.testRemoteAttachments[testAttachment.RemoteURL].Data, refreshedBytes)
}
@@ -196,9 +189,9 @@ func (suite *GetFileTestSuite) TestGetRemoteFileThumbnailUncached() {
MediaSize: string(media.SizeSmall),
FileName: fileName,
})
-
suite.NoError(errWithCode)
suite.NotNil(content)
+
b, err := io.ReadAll(content.Content)
suite.NoError(err)
suite.NoError(content.Content.Close())
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 55ec0d167..d05fe3519 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -24,6 +24,7 @@ import (
"io"
"mime"
"net/url"
+ "os"
"path"
"syscall"
"time"
@@ -95,6 +96,30 @@ func (d *Driver) PutStream(ctx context.Context, key string, r io.Reader) (int64,
return d.Storage.WriteStream(ctx, key, r)
}
+// PutFile moves the contents of file at path, to storage.Driver{} under given key.
+func (d *Driver) PutFile(ctx context.Context, key string, filepath string) (int64, error) {
+ // Open file at path for reading.
+ file, err := os.Open(filepath)
+ if err != nil {
+ return 0, gtserror.Newf("error opening file %s: %w", filepath, err)
+ }
+
+ // Write the file data to storage under key. Note
+ // that for disk.DiskStorage{} this should end up
+ // being a highly optimized Linux sendfile syscall.
+ sz, err := d.Storage.WriteStream(ctx, key, file)
+ if err != nil {
+ err = gtserror.Newf("error writing file %s: %w", key, err)
+ }
+
+ // Close the file: done with it.
+ if e := file.Close(); e != nil {
+ log.Errorf(ctx, "error closing file %s: %v", filepath, e)
+ }
+
+ return sz, err
+}
+
// Delete attempts to remove the supplied key (and corresponding value) from storage.
func (d *Driver) Delete(ctx context.Context, key string) error {
return d.Storage.Remove(ctx, key)
diff --git a/internal/transport/derefmedia.go b/internal/transport/derefmedia.go
index 265a9e77e..873032f39 100644
--- a/internal/transport/derefmedia.go
+++ b/internal/transport/derefmedia.go
@@ -23,30 +23,42 @@ import (
"net/http"
"net/url"
+ "codeberg.org/gruf/go-bytesize"
+ "codeberg.org/gruf/go-iotools"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
-func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, int64, error) {
+func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL, maxsz int64) (io.ReadCloser, error) {
// Build IRI just once
iriStr := iri.String()
// Prepare HTTP request to this media's IRI
req, err := http.NewRequestWithContext(ctx, "GET", iriStr, nil)
if err != nil {
- return nil, 0, err
+ return nil, err
}
req.Header.Add("Accept", "*/*") // we don't know what kind of media we're going to get here
// Perform the HTTP request
rsp, err := t.GET(req)
if err != nil {
- return nil, 0, err
+ return nil, err
}
// Check for an expected status code
if rsp.StatusCode != http.StatusOK {
- return nil, 0, gtserror.NewFromResponse(rsp)
+ return nil, gtserror.NewFromResponse(rsp)
}
- return rsp.Body, rsp.ContentLength, nil
+ // Check media within size limit.
+ if rsp.ContentLength > maxsz {
+ _ = rsp.Body.Close() // close early.
+ sz := bytesize.Size(maxsz) // nicer log format
+ return nil, gtserror.Newf("media body exceeds max size %s", sz)
+ }
+
+ // Update response body with maximum supported media size.
+ rsp.Body, _, _ = iotools.UpdateReadCloserLimit(rsp.Body, maxsz)
+
+ return rsp.Body, nil
}
diff --git a/internal/transport/transport.go b/internal/transport/transport.go
index 110c19b3d..2971ca603 100644
--- a/internal/transport/transport.go
+++ b/internal/transport/transport.go
@@ -67,8 +67,8 @@ type Transport interface {
// Dereference fetches the ActivityStreams object located at this IRI with a GET request.
Dereference(ctx context.Context, iri *url.URL) (*http.Response, error)
- // DereferenceMedia fetches the given media attachment IRI, returning the reader and filesize.
- DereferenceMedia(ctx context.Context, iri *url.URL) (io.ReadCloser, int64, error)
+ // DereferenceMedia fetches the given media attachment IRI, returning the reader limited to given max.
+ DereferenceMedia(ctx context.Context, iri *url.URL, maxsz int64) (io.ReadCloser, error)
// DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo.
DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error)
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 733a21b75..c0cd3d7e7 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -1385,9 +1385,9 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
instance.Configuration.Statuses.SupportedMimeTypes = instanceStatusesSupportedMimeTypes
instance.Configuration.MediaAttachments.SupportedMimeTypes = media.SupportedMIMETypes
- instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaImageMaxSize())
+ instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaRemoteMaxSize())
instance.Configuration.MediaAttachments.ImageMatrixLimit = instanceMediaAttachmentsImageMatrixLimit
- instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaVideoMaxSize())
+ instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaRemoteMaxSize())
instance.Configuration.MediaAttachments.VideoFrameRateLimit = instanceMediaAttachmentsVideoFrameRateLimit
instance.Configuration.MediaAttachments.VideoMatrixLimit = instanceMediaAttachmentsVideoMatrixLimit
instance.Configuration.Polls.MaxOptions = config.GetStatusesPollMaxOptions()
@@ -1525,9 +1525,9 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
instance.Configuration.Statuses.SupportedMimeTypes = instanceStatusesSupportedMimeTypes
instance.Configuration.MediaAttachments.SupportedMimeTypes = media.SupportedMIMETypes
- instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaImageMaxSize())
+ instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaRemoteMaxSize())
instance.Configuration.MediaAttachments.ImageMatrixLimit = instanceMediaAttachmentsImageMatrixLimit
- instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaVideoMaxSize())
+ instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaRemoteMaxSize())
instance.Configuration.MediaAttachments.VideoFrameRateLimit = instanceMediaAttachmentsVideoFrameRateLimit
instance.Configuration.MediaAttachments.VideoMatrixLimit = instanceMediaAttachmentsVideoMatrixLimit
instance.Configuration.Polls.MaxOptions = config.GetStatusesPollMaxOptions()
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 522bf6401..1195bc137 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -1217,7 +1217,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -1342,7 +1342,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
"image/webp",
"video/mp4"
],
- "image_size_limit": 10485760,
+ "image_size_limit": 41943040,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
@@ -1433,7 +1433,7 @@ func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin1() {
"id": "01F8MH9H8E4VG3KDYJR9EGPXCQ",
"disabled": false,
"updated_at": "2021-09-20T10:40:37.000Z",
- "total_file_size": 47115,
+ "total_file_size": 42794,
"content_type": "image/png",
"uri": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"
}`, string(b))
@@ -1455,7 +1455,7 @@ func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin2() {
"disabled": false,
"domain": "fossbros-anonymous.io",
"updated_at": "2020-03-18T12:12:00.000Z",
- "total_file_size": 21697,
+ "total_file_size": 19854,
"content_type": "image/png",
"uri": "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW"
}`, string(b))