From 53180548083c0a100db2f703d5f5da047a9e0031 Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Wed, 11 Jan 2023 11:13:13 +0000 Subject: [PATCH] [performance] media processing improvements (#1288) * media processor consolidation and reformatting, reduce amount of required syscalls Signed-off-by: kim * update go-store library, stream jpeg/png encoding + use buffer pools, improved media processing AlreadyExists error handling Signed-off-by: kim * fix duration not being set, fix mp4 test expecting error Signed-off-by: kim * fix test expecting media files with different extension Signed-off-by: kim * remove unused code Signed-off-by: kim * fix expected storage paths in tests, update expected test thumbnails Signed-off-by: kim * remove dead code Signed-off-by: kim * fix cached presigned s3 url fetching Signed-off-by: kim * fix tests Signed-off-by: kim * fix test models Signed-off-by: kim * update media processing to use sync.Once{} for concurrency protection Signed-off-by: kim * shutup linter Signed-off-by: kim * fix passing in KVStore GetStream() as stream to PutStream() Signed-off-by: kim * fix unlocks of storage keys Signed-off-by: kim * whoops, return the error... Signed-off-by: kim * pour one out for tobi's code <3 Signed-off-by: kim * add back the byte slurping code Signed-off-by: kim * check for both ErrUnexpectedEOF and EOF Signed-off-by: kim * add back links to file format header information Signed-off-by: kim Signed-off-by: kim --- go.mod | 7 +- go.sum | 15 +- .../api/client/accounts/accountupdate_test.go | 4 +- .../api/client/accounts/accountverify_test.go | 8 +- internal/api/fileserver/servefile.go | 13 +- internal/api/fileserver/servefile_test.go | 18 +- .../federation/dereferencing/media_test.go | 12 +- internal/iotools/io.go | 38 ++ internal/media/image.go | 301 ++++----- internal/media/manager.go | 6 - internal/media/manager_test.go | 31 +- internal/media/png-stripper.go | 12 - internal/media/processingemoji.go | 387 +++++------ internal/media/processingmedia.go | 611 ++++++++---------- internal/media/pruneorphaned_test.go | 4 +- internal/media/pruneremote_test.go | 2 +- internal/media/test/longer-mp4-thumbnail.jpg | Bin 3784 -> 2897 bytes internal/media/test/test-jpeg-thumbnail.jpg | Bin 22858 -> 20973 bytes internal/media/test/test-mp4-thumbnail.jpg | Bin 1912 -> 1913 bytes .../test/test-png-alphachannel-thumbnail.jpg | Bin 6446 -> 5984 bytes .../test-png-noalphachannel-thumbnail.jpg | Bin 6446 -> 5984 bytes internal/media/types.go | 29 - internal/media/util.go | 96 +-- internal/media/video.go | 138 ++-- internal/processing/account/getrss_test.go | 4 +- internal/processing/admin/updateemoji.go | 5 +- internal/processing/media/getfile.go | 166 ++--- internal/storage/storage.go | 21 +- internal/typeutils/internaltoas_test.go | 10 +- internal/typeutils/internaltofrontend.go | 2 +- internal/typeutils/internaltofrontend_test.go | 14 +- internal/typeutils/internaltorss_test.go | 2 +- internal/validate/mediaattachment_test.go | 4 +- ...ohyou-original.jpeg => ohyou-original.jpg} | Bin .../{ohyou-small.jpeg => ohyou-small.jpg} | Bin ...iginal.jpeg => team-fortress-original.jpg} | Bin ...ess-small.jpeg => team-fortress-small.jpg} | Bin ...iginal.jpeg => thoughtsofdog-original.jpg} | Bin testrig/media/thoughtsofdog-small.jpeg | Bin 20395 -> 0 bytes testrig/media/thoughtsofdog-small.jpg | Bin 0 -> 19312 bytes .../{trent-small.jpeg => trent-small.jpg} | Bin ...ome-original.jpeg => welcome-original.jpg} | Bin .../{welcome-small.jpeg => welcome-small.jpg} | Bin .../{zork-original.jpeg => zork-original.jpg} | Bin .../media/{zork-small.jpeg => zork-small.jpg} | Bin testrig/storage.go | 8 +- testrig/testmodels.go | 108 ++-- vendor/codeberg.org/gruf/go-fastcopy/copy.go | 6 +- vendor/codeberg.org/gruf/go-iotools/LICENSE | 9 + vendor/codeberg.org/gruf/go-iotools/close.go | 35 + vendor/codeberg.org/gruf/go-iotools/read.go | 28 + vendor/codeberg.org/gruf/go-iotools/write.go | 26 + vendor/codeberg.org/gruf/go-mutexes/map.go | 4 +- .../codeberg.org/gruf/go-store/v2/kv/state.go | 8 +- .../codeberg.org/gruf/go-store/v2/kv/store.go | 22 +- .../gruf/go-store/v2/storage/block.go | 52 +- .../gruf/go-store/v2/storage/compressor.go | 173 +++-- .../gruf/go-store/v2/storage/disk.go | 33 +- .../gruf/go-store/v2/storage/fs.go | 37 +- .../gruf/go-store/v2/storage/memory.go | 26 +- .../gruf/go-store/v2/storage/s3.go | 43 +- .../gruf/go-store/v2/storage/storage.go | 4 +- .../codeberg.org/gruf/go-store/v2/util/io.go | 93 +-- vendor/modules.txt | 9 +- 64 files changed, 1279 insertions(+), 1405 deletions(-) rename testrig/media/{ohyou-original.jpeg => ohyou-original.jpg} (100%) rename testrig/media/{ohyou-small.jpeg => ohyou-small.jpg} (100%) rename testrig/media/{team-fortress-original.jpeg => team-fortress-original.jpg} (100%) rename testrig/media/{team-fortress-small.jpeg => team-fortress-small.jpg} (100%) rename testrig/media/{thoughtsofdog-original.jpeg => thoughtsofdog-original.jpg} (100%) delete mode 100644 testrig/media/thoughtsofdog-small.jpeg create mode 100644 testrig/media/thoughtsofdog-small.jpg rename testrig/media/{trent-small.jpeg => trent-small.jpg} (100%) rename testrig/media/{welcome-original.jpeg => welcome-original.jpg} (100%) rename testrig/media/{welcome-small.jpeg => welcome-small.jpg} (100%) rename testrig/media/{zork-original.jpeg => zork-original.jpg} (100%) rename testrig/media/{zork-small.jpeg => zork-small.jpg} (100%) create mode 100644 vendor/codeberg.org/gruf/go-iotools/LICENSE create mode 100644 vendor/codeberg.org/gruf/go-iotools/close.go create mode 100644 vendor/codeberg.org/gruf/go-iotools/read.go create mode 100644 vendor/codeberg.org/gruf/go-iotools/write.go diff --git a/go.mod b/go.mod index 83a7b677a..0397257da 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,9 @@ require ( codeberg.org/gruf/go-errors/v2 v2.0.2 codeberg.org/gruf/go-kv v1.5.2 codeberg.org/gruf/go-logger/v2 v2.2.1 - codeberg.org/gruf/go-mutexes v1.1.4 + codeberg.org/gruf/go-mutexes v1.1.5 codeberg.org/gruf/go-runners v1.4.0 - codeberg.org/gruf/go-store/v2 v2.0.10 + codeberg.org/gruf/go-store/v2 v2.2.1 github.com/abema/go-mp4 v0.9.0 github.com/buckket/go-blurhash v1.1.0 github.com/coreos/go-oidc/v3 v3.5.0 @@ -65,10 +65,11 @@ require ( codeberg.org/gruf/go-atomics v1.1.0 // indirect codeberg.org/gruf/go-bitutil v1.0.1 // indirect codeberg.org/gruf/go-bytes v1.0.2 // indirect - codeberg.org/gruf/go-fastcopy v1.1.1 // indirect + codeberg.org/gruf/go-fastcopy v1.1.2 // indirect codeberg.org/gruf/go-fastpath v1.0.3 // indirect codeberg.org/gruf/go-fastpath/v2 v2.0.0 // indirect codeberg.org/gruf/go-hashenc v1.0.2 // indirect + codeberg.org/gruf/go-iotools v0.0.0-20221224124424-3386841cb225 // indirect codeberg.org/gruf/go-mangler v1.2.2 // indirect codeberg.org/gruf/go-maps v1.0.3 // indirect codeberg.org/gruf/go-pools v1.1.0 // indirect diff --git a/go.sum b/go.sum index 3e5affbe0..53751c37d 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ codeberg.org/gruf/go-debug v1.2.0/go.mod h1:N+vSy9uJBQgpQcJUqjctvqFz7tBHJf+S/PIj codeberg.org/gruf/go-errors/v2 v2.0.0/go.mod h1:ZRhbdhvgoUA3Yw6e56kd9Ox984RrvbEFC2pOXyHDJP4= codeberg.org/gruf/go-errors/v2 v2.0.2 h1:T9CqfC+ntSIQL5mdQxwHlUMod1htpgNe3P1tugxKlT4= codeberg.org/gruf/go-errors/v2 v2.0.2/go.mod h1:6sI75OmvXE2AtRm4WUyGMEyqEOKTsfe+CA+aBXwbtJY= -codeberg.org/gruf/go-fastcopy v1.1.1 h1:HhPCeFdVR5pwiSVDnQEGJ+J2ny9b5QgfiESc0zrWQAY= -codeberg.org/gruf/go-fastcopy v1.1.1/go.mod h1:GDDYR0Cnb3U/AIfGM3983V/L+GN+vuwVMvrmVABo21s= +codeberg.org/gruf/go-fastcopy v1.1.2 h1:YwmYXPsyOcRBxKEE2+w1bGAZfclHVaPijFsOVOcnNcw= +codeberg.org/gruf/go-fastcopy v1.1.2/go.mod h1:GDDYR0Cnb3U/AIfGM3983V/L+GN+vuwVMvrmVABo21s= codeberg.org/gruf/go-fastpath v1.0.1/go.mod h1:edveE/Kp3Eqi0JJm0lXYdkVrB28cNUkcb/bRGFTPqeI= codeberg.org/gruf/go-fastpath v1.0.3 h1:3Iftz9Z2suCEgTLkQMucew+2+4Oe46JPbAM2JEhnjTU= codeberg.org/gruf/go-fastpath v1.0.3/go.mod h1:edveE/Kp3Eqi0JJm0lXYdkVrB28cNUkcb/bRGFTPqeI= @@ -65,6 +65,8 @@ codeberg.org/gruf/go-fastpath/v2 v2.0.0 h1:iAS9GZahFhyWEH0KLhFEJR+txx1ZhMXxYzu2q codeberg.org/gruf/go-fastpath/v2 v2.0.0/go.mod h1:3pPqu5nZjpbRrOqvLyAK7puS1OfEtQvjd6342Cwz56Q= codeberg.org/gruf/go-hashenc v1.0.2 h1:U3jH6zMXZiL96czD/qaJd8OR2h7LlBzGv/2WxnMHI/g= codeberg.org/gruf/go-hashenc v1.0.2/go.mod h1:eK+A8clLcEN/m1nftNsRId0kfYDQnETnuIfBGZ8Gvsg= +codeberg.org/gruf/go-iotools v0.0.0-20221224124424-3386841cb225 h1:tP9YvEBfADGG3mXkfrALLadlcbrZsFsWKZvFtUZtrt8= +codeberg.org/gruf/go-iotools v0.0.0-20221224124424-3386841cb225/go.mod h1:B8uq4yHtIcKXhBZT9C/SYisz25lldLHMVpwZPz4ADLQ= codeberg.org/gruf/go-kv v1.5.2 h1:B0RkAXLUXYn3Za1NzTXOcUvAc+JUC2ZadTMkCUDa0mc= codeberg.org/gruf/go-kv v1.5.2/go.mod h1:al6ASW/2CbGqz2YcM8B00tvWnVi1bU1CH3HYs5tZxo4= codeberg.org/gruf/go-logger/v2 v2.2.1 h1:RP2u059EQKTBFV3cN8X6xDxNk2RkzqdgXGKflKqB7Oc= @@ -73,16 +75,16 @@ codeberg.org/gruf/go-mangler v1.2.2 h1:fisdWXa6dW4p1uYdbz5Of3R4lDDFPuRqKavGI9O03 codeberg.org/gruf/go-mangler v1.2.2/go.mod h1:X/7URkFhLBAVKkTxmqF11Oxw3A6pSSxgPeHssQaiq28= codeberg.org/gruf/go-maps v1.0.3 h1:VDwhnnaVNUIy5O93CvkcE2IZXnMB1+IJjzfop9V12es= codeberg.org/gruf/go-maps v1.0.3/go.mod h1:D5LNDxlC9rsDuVQVM6JObaVGAdHB6g2dTdOdkh1aXWA= -codeberg.org/gruf/go-mutexes v1.1.4 h1:HWaIZavPL92SBJxNOlIXAmAT5CB2hAs72/lBN31jnzM= -codeberg.org/gruf/go-mutexes v1.1.4/go.mod h1:1j/6/MBeBQUedAtAtysLLnBKogfOZAxdym0E3wlaBD8= +codeberg.org/gruf/go-mutexes v1.1.5 h1:8Y8DwCGf24MyzOSaPvLrtk/B4ecVx4z+fppL6dY+PG8= +codeberg.org/gruf/go-mutexes v1.1.5/go.mod h1:1j/6/MBeBQUedAtAtysLLnBKogfOZAxdym0E3wlaBD8= codeberg.org/gruf/go-pools v1.1.0 h1:LbYP24eQLl/YI1fSU2pafiwhGol1Z1zPjRrMsXpF88s= codeberg.org/gruf/go-pools v1.1.0/go.mod h1:ZMYpt/DjQWYC3zFD3T97QWSFKs62zAUGJ/tzvgB9D68= codeberg.org/gruf/go-runners v1.4.0 h1:977nVjigAdH95+VAB/a6tyBJOKk99e60h+mfHzBs/n8= codeberg.org/gruf/go-runners v1.4.0/go.mod h1:kUM6GYL7dC+f9Sc/XuwdvB/mB4FuI4fJFb150ADMsmw= codeberg.org/gruf/go-sched v1.2.0 h1:utZl/7srVcbh30rFw42LC2/cMtak4UZRxtIOt/5riNA= codeberg.org/gruf/go-sched v1.2.0/go.mod h1:v4ueWq+fAtAw9JYt4aFXvadI1YoOqofgHQgszRYuslA= -codeberg.org/gruf/go-store/v2 v2.0.10 h1:/2iZ4j29A//EhM3XziJP6SxtdIcaAyPmJEv31+6XD8g= -codeberg.org/gruf/go-store/v2 v2.0.10/go.mod h1:KMRE173S6W2sGhuIa4jY/OPIO65F9++7rmWTfZ4xTeY= +codeberg.org/gruf/go-store/v2 v2.2.1 h1:lbvMjhMLebefiaPNLtWvPySKSYM5xN1aztSxxz+vCzU= +codeberg.org/gruf/go-store/v2 v2.2.1/go.mod h1:pxdyfSzau8fFs1TfZlyRzhDYvZWLaj1sXpcjXpzBB6k= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -818,6 +820,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/api/client/accounts/accountupdate_test.go b/internal/api/client/accounts/accountupdate_test.go index ad28d2e90..9ccb29302 100644 --- a/internal/api/client/accounts/accountupdate_test.go +++ b/internal/api/client/accounts/accountupdate_test.go @@ -300,8 +300,8 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWit suite.NotEmpty(apimodelAccount.HeaderStatic) // should be different from the values set before - suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) - suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) + suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", apimodelAccount.Header) + suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", apimodelAccount.HeaderStatic) } func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerEmptyForm() { diff --git a/internal/api/client/accounts/accountverify_test.go b/internal/api/client/accounts/accountverify_test.go index 3ee18a7ef..f9cd8e30a 100644 --- a/internal/api/client/accounts/accountverify_test.go +++ b/internal/api/client/accounts/accountverify_test.go @@ -74,10 +74,10 @@ func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() { suite.Equal(*testAccount.Bot, apimodelAccount.Bot) suite.WithinDuration(testAccount.CreatedAt, createdAt, 30*time.Second) // we lose a bit of accuracy serializing so fuzz this a bit suite.Equal(testAccount.URL, apimodelAccount.URL) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.Avatar) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.AvatarStatic) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", apimodelAccount.Avatar) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", apimodelAccount.AvatarStatic) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", apimodelAccount.Header) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", apimodelAccount.HeaderStatic) suite.Equal(2, apimodelAccount.FollowersCount) suite.Equal(2, apimodelAccount.FollowingCount) suite.Equal(5, apimodelAccount.StatusesCount) diff --git a/internal/api/fileserver/servefile.go b/internal/api/fileserver/servefile.go index 951d16527..2b47db6f2 100644 --- a/internal/api/fileserver/servefile.go +++ b/internal/api/fileserver/servefile.go @@ -117,14 +117,19 @@ func (m *Module) ServeFile(c *gin.Context) { return } - // try to slurp the first few bytes to make sure we have something - b := bytes.NewBuffer(make([]byte, 0, 64)) - if _, err := io.CopyN(b, content.Content, 64); err != nil { + // create a "slurp" buffer ;) + b := make([]byte, 64) + + // Try read the first 64 bytes into memory, to try return a more useful "not found" error. + if _, err := io.ReadFull(content.Content, b); err != nil && + (err != io.ErrUnexpectedEOF && err != io.EOF) { err = fmt.Errorf("ServeFile: error reading from content: %w", err) apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) return } // we're good, return the slurped bytes + the rest of the content - c.DataFromReader(http.StatusOK, content.ContentLength, format, io.MultiReader(b, content.Content), nil) + c.DataFromReader(http.StatusOK, content.ContentLength, format, io.MultiReader( + bytes.NewReader(b), content.Content, + ), nil) } diff --git a/internal/api/fileserver/servefile_test.go b/internal/api/fileserver/servefile_test.go index f16dd9850..74d02dccb 100644 --- a/internal/api/fileserver/servefile_test.go +++ b/internal/api/fileserver/servefile_test.go @@ -99,7 +99,7 @@ func (suite *ServeFileTestSuite) TestServeOriginalLocalFileOK() { targetAttachment.AccountID, media.TypeAttachment, media.SizeOriginal, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -119,7 +119,7 @@ func (suite *ServeFileTestSuite) TestServeSmallLocalFileOK() { targetAttachment.AccountID, media.TypeAttachment, media.SizeSmall, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -139,7 +139,7 @@ func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileOK() { targetAttachment.AccountID, media.TypeAttachment, media.SizeOriginal, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -159,7 +159,7 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileOK() { targetAttachment.AccountID, media.TypeAttachment, media.SizeSmall, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -182,7 +182,7 @@ func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecache() { targetAttachment.AccountID, media.TypeAttachment, media.SizeOriginal, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -205,7 +205,7 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecache() { targetAttachment.AccountID, media.TypeAttachment, media.SizeSmall, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusOK, code) @@ -228,7 +228,7 @@ func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecacheNotFound() { targetAttachment.AccountID, media.TypeAttachment, media.SizeOriginal, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusNotFound, code) @@ -249,7 +249,7 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecacheNotFound() { targetAttachment.AccountID, media.TypeAttachment, media.SizeSmall, - targetAttachment.ID+".jpeg", + targetAttachment.ID+".jpg", ) suite.Equal(http.StatusNotFound, code) @@ -261,7 +261,7 @@ func (suite *ServeFileTestSuite) TestServeFileNotFound() { "01GMMY4G9B0QEG0PQK5Q5JGJWZ", media.TypeAttachment, media.SizeOriginal, - "01GMMY68Y7E5DJ3CA3Y9SS8524.jpeg", + "01GMMY68Y7E5DJ3CA3Y9SS8524.jpg", ) suite.Equal(http.StatusNotFound, code) diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go index a118b5bf4..09970c3ee 100644 --- a/internal/federation/dereferencing/media_test.go +++ b/internal/federation/dereferencing/media_test.go @@ -21,11 +21,11 @@ import ( "context" "testing" + "time" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AttachmentTestSuite struct { @@ -42,7 +42,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() { attachmentContentType := "image/jpeg" attachmentURL := "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg" attachmentDescription := "It's a cute plushie." - attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" + attachmentBlurhash := "LtQ9yKi__4%g%MRjWCt7%hozM_az" media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ StatusID: &attachmentStatus, @@ -116,7 +116,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentAsync() { attachmentContentType := "image/jpeg" attachmentURL := "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg" attachmentDescription := "It's a cute plushie." - attachmentBlurhash := "LwP?p=aK_4%N%MRjWXt7%hozM_a}" + attachmentBlurhash := "LtQ9yKi__4%g%MRjWCt7%hozM_az" processingMedia, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{ StatusID: &attachmentStatus, @@ -127,11 +127,7 @@ func (suite *AttachmentTestSuite) TestDereferenceAttachmentAsync() { suite.NoError(err) attachmentID := processingMedia.AttachmentID() - if !testrig.WaitFor(func() bool { - return processingMedia.Finished() - }) { - suite.FailNow("timed out waiting for media to be processed") - } + time.Sleep(time.Second * 3) // now get the attachment from the database attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) diff --git a/internal/iotools/io.go b/internal/iotools/io.go index 04b03850e..5f0c4b72c 100644 --- a/internal/iotools/io.go +++ b/internal/iotools/io.go @@ -119,3 +119,41 @@ func (w *SilentWriter) Write(b []byte) (int, error) { func (w *SilentWriter) Error() error { return w.err } + +func StreamReadFunc(read func(io.Reader) error) io.Writer { + // In-memory stream. + pr, pw := io.Pipe() + + go func() { + var err error + + defer func() { + // Always pass along error. + pr.CloseWithError(err) + }() + + // Start reading. + err = read(pr) + }() + + return pw +} + +func StreamWriteFunc(write func(io.Writer) error) io.Reader { + // In-memory stream. + pr, pw := io.Pipe() + + go func() { + var err error + + defer func() { + // Always pass along error. + pw.CloseWithError(err) + }() + + // Start writing. + err = write(pw) + }() + + return pr +} diff --git a/internal/media/image.go b/internal/media/image.go index b168c619e..b3eff6bec 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -19,182 +19,167 @@ package media import ( - "bytes" - "errors" - "fmt" + "bufio" "image" - "image/gif" + "image/color" + "image/draw" "image/jpeg" "image/png" "io" + "sync" "github.com/buckket/go-blurhash" "github.com/disintegration/imaging" - _ "golang.org/x/image/webp" // blank import to support WebP decoding + "github.com/superseriousbusiness/gotosocial/internal/iotools" + + // import to init webp encode/decoding. + _ "golang.org/x/image/webp" ) -const ( - thumbnailMaxWidth = 512 - thumbnailMaxHeight = 512 +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{ + New: func() any { + return bufio.NewWriter(nil) + }, + } ) -func decodeGif(r io.Reader) (*mediaMeta, error) { - gif, err := gif.DecodeAll(r) +// 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 >sImage{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 } - - // use the first frame to get the static characteristics - width := gif.Config.Width - height := gif.Config.Height - size := width * height - aspect := float32(width) / float32(height) - - return &mediaMeta{ - width: width, - height: height, - size: size, - aspect: aspect, - }, nil + return >sImage{image: img}, nil } -func decodeImage(r io.Reader, contentType string) (*mediaMeta, error) { - var i image.Image - var err error - - switch contentType { - case mimeImageJpeg, mimeImageWebp: - i, err = imaging.Decode(r, imaging.AutoOrientation(true)) - case mimeImagePng: - strippedPngReader := io.Reader(&PNGAncillaryChunkStripper{ - Reader: r, - }) - i, err = imaging.Decode(strippedPngReader, imaging.AutoOrientation(true)) - default: - err = fmt.Errorf("content type %s not recognised", contentType) - } - - if err != nil { - return nil, err - } - - if i == nil { - return nil, errors.New("processed image was nil") - } - - width := i.Bounds().Size().X - height := i.Bounds().Size().Y - size := width * height - aspect := float32(width) / float32(height) - - return &mediaMeta{ - width: width, - height: height, - size: size, - aspect: aspect, - }, nil +// Width returns the image width in pixels. +func (m *gtsImage) Width() uint32 { + return uint32(m.image.Bounds().Size().X) } -// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. -func deriveStaticEmoji(r io.Reader, contentType string) (*mediaMeta, error) { - var i image.Image - var err error - - switch contentType { - case mimeImagePng: - i, err = StrippedPngDecode(r) - if err != nil { - return nil, err - } - case mimeImageGif: - i, err = gif.Decode(r) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) - } - - out := &bytes.Buffer{} - if err := png.Encode(out, i); err != nil { - return nil, err - } - return &mediaMeta{ - small: out.Bytes(), - }, nil +// Height returns the image height in pixels. +func (m *gtsImage) Height() uint32 { + return uint32(m.image.Bounds().Size().Y) } -// deriveThumbnailFromImage returns a byte slice and metadata for a thumbnail -// of a given piece of media, or an error if something goes wrong. -// -// If createBlurhash is true, then a blurhash will also be generated from a tiny -// version of the image. This costs precious CPU cycles, so only use it if you -// really need a blurhash and don't have one already. -// -// If createBlurhash is false, then the blurhash field on the returned ImageAndMeta -// will be an empty string. -func deriveThumbnailFromImage(r io.Reader, contentType string, createBlurhash bool) (*mediaMeta, error) { - var i image.Image - var err error - - switch contentType { - case mimeImageJpeg, mimeImageGif, mimeImageWebp: - i, err = imaging.Decode(r, imaging.AutoOrientation(true)) - case mimeImagePng: - strippedPngReader := io.Reader(&PNGAncillaryChunkStripper{ - Reader: r, - }) - i, err = imaging.Decode(strippedPngReader, imaging.AutoOrientation(true)) - default: - err = fmt.Errorf("content type %s can't be thumbnailed as an image", contentType) - } - - if err != nil { - return nil, fmt.Errorf("error decoding %s: %s", contentType, err) - } - - originalX := i.Bounds().Size().X - originalY := i.Bounds().Size().Y - - var thumb image.Image - if originalX <= thumbnailMaxWidth && originalY <= thumbnailMaxHeight { - // it's already small, no need to resize - thumb = i - } else { - thumb = imaging.Fit(i, thumbnailMaxWidth, thumbnailMaxHeight, imaging.Linear) - } - - thumbX := thumb.Bounds().Size().X - thumbY := thumb.Bounds().Size().Y - size := thumbX * thumbY - aspect := float32(thumbX) / float32(thumbY) - - im := &mediaMeta{ - width: thumbX, - height: thumbY, - size: size, - aspect: aspect, - } - - if createBlurhash { - // for generating blurhashes, it's more cost effective to lose detail rather than - // pass a big image into the blurhash algorithm, so make a teeny tiny version - tiny := imaging.Resize(thumb, 32, 0, imaging.NearestNeighbor) - bh, err := blurhash.Encode(4, 3, tiny) - if err != nil { - return nil, fmt.Errorf("error creating blurhash: %s", err) - } - im.blurhash = bh - } - - out := &bytes.Buffer{} - if err := jpeg.Encode(out, thumb, &jpeg.Options{ - // Quality isn't extremely important for thumbnails, so 75 is "good enough" - Quality: 75, - }); err != nil { - return nil, fmt.Errorf("error encoding thumbnail: %s", err) - } - im.small = out.Bytes() - - return im, nil +// Size returns the total number of image pixels. +func (m *gtsImage) Size() uint64 { + return uint64(m.image.Bounds().Size().X) * + uint64(m.image.Bounds().Size().Y) +} + +// AspectRatio returns the image ratio of width:height. +func (m *gtsImage) AspectRatio() float32 { + return float32(m.image.Bounds().Size().X) / + float32(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 >sImage{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 >sImage{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 { + buf, _ := jpegBufferPool.Get().(*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 9b1d87673..44483787a 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -148,9 +148,6 @@ func NewManager(database db.DB, storage *storage.Driver) (Manager, error) { // Prepare the media worker pool m.mediaWorker = concurrency.NewWorkerPool[*ProcessingMedia](-1, 10) m.mediaWorker.SetProcessor(func(ctx context.Context, media *ProcessingMedia) error { - if err := ctx.Err(); err != nil { - return err - } if _, err := media.LoadAttachment(ctx); err != nil { return fmt.Errorf("error loading media %s: %v", media.AttachmentID(), err) } @@ -160,9 +157,6 @@ func NewManager(database db.DB, storage *storage.Driver) (Manager, error) { // Prepare the emoji worker pool m.emojiWorker = concurrency.NewWorkerPool[*ProcessingEmoji](-1, 10) m.emojiWorker.SetProcessor(func(ctx context.Context, emoji *ProcessingEmoji) error { - if err := ctx.Err(); err != nil { - return err - } if _, err := emoji.LoadEmoji(ctx); err != nil { return fmt.Errorf("error loading emoji %s: %v", emoji.EmojiID(), err) } diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 1abf8c3ce..8febaddae 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -26,6 +26,7 @@ "os" "path" "testing" + "time" "codeberg.org/gruf/go-store/v2/kv" "codeberg.org/gruf/go-store/v2/storage" @@ -33,7 +34,6 @@ gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" gtsstorage "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" ) type ManagerTestSuite struct { @@ -214,7 +214,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() { // do a blocking call to fetch the emoji emoji, err := processingEmoji.LoadEmoji(ctx) - suite.EqualError(err, "store: given emoji fileSize (645688b) is larger than allowed size (51200b)") + suite.EqualError(err, "given emoji size 630kiB greater than max allowed 50.0kiB") suite.Nil(emoji) } @@ -227,7 +227,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { if err != nil { panic(err) } - return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil + return io.NopCloser(bytes.NewBuffer(b)), -1, nil } emojiID := "01GDQ9G782X42BAMFASKP64343" @@ -238,7 +238,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { // do a blocking call to fetch the emoji emoji, err := processingEmoji.LoadEmoji(ctx) - suite.EqualError(err, "store: given emoji fileSize (645688b) is larger than allowed size (51200b)") + suite.EqualError(err, "calculated emoji size 630kiB greater than max allowed 50.0kiB") suite.Nil(emoji) } @@ -396,6 +396,9 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { // fetch the attachment id from the processing media attachmentID := processingMedia.AttachmentID() + // Give time for processing + time.Sleep(time.Second * 3) + // do a blocking call to fetch the attachment attachment, err := processingMedia.LoadAttachment(ctx) suite.NoError(err) @@ -420,7 +423,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { suite.Equal("video/mp4", attachment.File.ContentType) suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) suite.Equal(312413, attachment.File.FileSize) - suite.Equal("", attachment.Blurhash) + suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) @@ -491,12 +494,12 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { suite.EqualValues(10, *attachment.FileMeta.Original.Framerate) suite.EqualValues(0xc8fb, *attachment.FileMeta.Original.Bitrate) suite.EqualValues(gtsmodel.Small{ - Width: 600, Height: 330, Size: 198000, Aspect: 1.8181819, + 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("", attachment.Blurhash) + suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) @@ -550,7 +553,7 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { // we should get an error while loading attachment, err := processingMedia.LoadAttachment(ctx) - suite.EqualError(err, "\"video width could not be discovered\",\"video height could not be discovered\",\"video duration could not be discovered\",\"video framerate could not be discovered\",\"video bitrate could not be discovered\"") + suite.EqualError(err, "error decoding video: error determining video metadata: [width height duration framerate bitrate]") suite.Nil(attachment) } @@ -928,7 +931,8 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { } func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { - ctx := context.Background() + ctx, cncl := context.WithTimeout(context.Background(), time.Second*30) + defer cncl() data := func(_ context.Context) (io.ReadCloser, int64, error) { // load bytes from a test image @@ -944,15 +948,12 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() { // process the media with no additional info provided processingMedia, err := suite.manager.ProcessMedia(ctx, data, nil, accountID, nil) suite.NoError(err) + // fetch the attachment id from the processing media attachmentID := processingMedia.AttachmentID() - // wait for the media to finish processing - if !testrig.WaitFor(func() bool { - return processingMedia.Finished() - }) { - suite.FailNow("timed out waiting for media to be processed") - } + // Give time for processing to happen. + time.Sleep(time.Second * 3) // fetch the attachment from the database attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) diff --git a/internal/media/png-stripper.go b/internal/media/png-stripper.go index be5e80387..79b0bac05 100644 --- a/internal/media/png-stripper.go +++ b/internal/media/png-stripper.go @@ -75,8 +75,6 @@ import ( "encoding/binary" - "image" - "image/png" "io" ) @@ -192,13 +190,3 @@ func (r *PNGAncillaryChunkStripper) Read(p []byte) (int, error) { } } } - -// StrippedPngDecode strips ancillary data from png to allow more lenient decoding of pngs -// see: https://github.com/golang/go/issues/43382 -// and: https://github.com/google/wuffs/blob/414a011491ff513b86d8694c5d71800f3cb5a715/script/strip-png-ancillary-chunks.go -func StrippedPngDecode(r io.Reader) (image.Image, error) { - strippedPngReader := io.Reader(&PNGAncillaryChunkStripper{ - Reader: r, - }) - return png.Decode(strippedPngReader) -} diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index de47d23a8..b68c9dfe1 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -24,84 +24,74 @@ "errors" "fmt" "io" - "strings" "sync" - "sync/atomic" "time" + "codeberg.org/gruf/go-bytesize" gostore "codeberg.org/gruf/go-store/v2/storage" + "github.com/h2non/filetype" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/uris" ) // ProcessingEmoji represents an emoji currently processing. It exposes // various functions for retrieving data from the process. type ProcessingEmoji struct { - mu sync.Mutex - - // id of this instance's account -- pinned for convenience here so we only need to fetch it once - instanceAccountID string - - /* - below fields should be set on newly created media; - emoji will be updated incrementally as media goes through processing - */ - - emoji *gtsmodel.Emoji - data DataFunc - postData PostDataCallbackFunc - read bool // bool indicating that data function has been triggered already - - /* - below fields represent the processing state of the static of the emoji - */ - staticState int32 - - /* - below pointers to database and storage are maintained so that - the media can store and update itself during processing steps - */ - - database db.DB - storage *storage.Driver - - err error // error created during processing, if any - - // track whether this emoji has already been put in the databse - insertedInDB bool - - // is this a refresh of an existing emoji? - refresh bool - // if it is a refresh, which alternate ID should we use in the storage and URL paths? - newPathID string + instAccID string // instance account ID + emoji *gtsmodel.Emoji // processing emoji details + refresh bool // whether this is an existing emoji being refreshed + newPathID string // new emoji path ID to use if refreshed + dataFn DataFunc // load-data function, returns media stream + postFn PostDataCallbackFunc // post data callback function + err error // error encountered during processing + manager *manager // manager instance (access to db / storage) + once sync.Once // once ensures processing only occurs once } // EmojiID returns the ID of the underlying emoji without blocking processing. func (p *ProcessingEmoji) EmojiID() string { - return p.emoji.ID + return p.emoji.ID // immutable, safe outside mutex. } // LoadEmoji blocks until the static and fullsize image // has been processed, and then returns the completed emoji. func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - p.mu.Lock() - defer p.mu.Unlock() + // only process once. + p.once.Do(func() { + var err error - if err := p.store(ctx); err != nil { - return nil, err - } + defer func() { + if r := recover(); r != nil { + if err != nil { + rOld := r // wrap the panic so we don't lose existing returned error + r = fmt.Errorf("panic occured after error %q: %v", err.Error(), rOld) + } - if err := p.loadStatic(ctx); err != nil { - return nil, err - } + // Catch any panics and wrap as error. + err = fmt.Errorf("caught panic: %v", r) + } + + if err != nil { + // Store error. + p.err = err + } + }() + + // Attempt to store media and calculate + // full-size media attachment details. + if err = p.store(ctx); err != nil { + return + } + + // Finish processing by reloading media into + // memory to get dimension and generate a thumb. + if err = p.finish(ctx); err != nil { + return + } - // store the result in the database before returning it - if !p.insertedInDB { if p.refresh { columns := []string{ "updated_at", @@ -118,176 +108,195 @@ func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error "shortcode", "uri", } - if _, err := p.database.UpdateEmoji(ctx, p.emoji, columns...); err != nil { - return nil, err - } - } else { - if err := p.database.PutEmoji(ctx, p.emoji); err != nil { - return nil, err - } + + // Existing emoji we're refreshing, so only need to update. + _, err = p.manager.db.UpdateEmoji(ctx, p.emoji, columns...) + return } - p.insertedInDB = true + + // New emoji media, first time caching. + err = p.manager.db.PutEmoji(ctx, p.emoji) + return //nolint shutup linter i like this here + }) + + if p.err != nil { + return nil, p.err } return p.emoji, nil } -// Finished returns true if processing has finished for both the thumbnail -// and full fized version of this piece of media. -func (p *ProcessingEmoji) Finished() bool { - return atomic.LoadInt32(&p.staticState) == int32(complete) -} - -func (p *ProcessingEmoji) loadStatic(ctx context.Context) error { - staticState := atomic.LoadInt32(&p.staticState) - switch processState(staticState) { - case received: - // stream the original file out of storage... - stored, err := p.storage.GetStream(ctx, p.emoji.ImagePath) - if err != nil { - p.err = fmt.Errorf("loadStatic: error fetching file from storage: %s", err) - atomic.StoreInt32(&p.staticState, int32(errored)) - return p.err - } - defer stored.Close() - - // we haven't processed a static version of this emoji yet so do it now - static, err := deriveStaticEmoji(stored, p.emoji.ImageContentType) - if err != nil { - p.err = fmt.Errorf("loadStatic: error deriving static: %s", err) - atomic.StoreInt32(&p.staticState, int32(errored)) - return p.err - } - - // Close stored emoji now we're done - if err := stored.Close(); err != nil { - log.Errorf("loadStatic: error closing stored full size: %s", err) - } - - // put the static image in storage - if err := p.storage.Put(ctx, p.emoji.ImageStaticPath, static.small); err != nil && err != storage.ErrAlreadyExists { - p.err = fmt.Errorf("loadStatic: error storing static: %s", err) - atomic.StoreInt32(&p.staticState, int32(errored)) - return p.err - } - - p.emoji.ImageStaticFileSize = len(static.small) - - // we're done processing the static version of the emoji! - atomic.StoreInt32(&p.staticState, int32(complete)) - fallthrough - case complete: - return nil - case errored: - return p.err - } - - return fmt.Errorf("static processing status %d unknown", p.staticState) -} - // store calls the data function attached to p if it hasn't been called yet, // and updates the underlying attachment fields as necessary. It will then stream // bytes from p's reader directly into storage so that it can be retrieved later. func (p *ProcessingEmoji) store(ctx context.Context) error { - // check if we've already done this and bail early if we have - if p.read { - return nil - } - - // execute the data function to get the readcloser out of it - rc, fileSize, err := p.data(ctx) - if err != nil { - return fmt.Errorf("store: error executing data function: %s", err) - } - - // defer closing the reader when we're done with it defer func() { + if p.postFn == nil { + return + } + + // Ensure post callback gets called. + if err := p.postFn(ctx); err != nil { + log.Errorf("error executing postdata function: %v", err) + } + }() + + // Load media from provided data fn. + rc, sz, err := p.dataFn(ctx) + if err != nil { + return fmt.Errorf("error executing data function: %w", err) + } + + defer func() { + // Ensure data reader gets closed on return. if err := rc.Close(); err != nil { - log.Errorf("store: error closing readcloser: %s", err) + log.Errorf("error closing data reader: %v", err) } }() - // execute the postData function no matter what happens - defer func() { - if p.postData != nil { - if err := p.postData(ctx); err != nil { - log.Errorf("store: error executing postData: %s", err) - } - } - }() + // Byte buffer to read file header into. + // See: https://en.wikipedia.org/wiki/File_format#File_header + // and https://github.com/h2non/filetype + hdrBuf := make([]byte, 261) - // extract no more than 261 bytes from the beginning of the file -- this is the header - firstBytes := make([]byte, maxFileHeaderBytes) - if _, err := rc.Read(firstBytes); err != nil { - return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err) + // Read the first 261 header bytes into buffer. + if _, err := io.ReadFull(rc, hdrBuf); err != nil { + return fmt.Errorf("error reading incoming media: %w", err) } - // now we have the file header we can work out the content type from it - contentType, err := parseContentType(firstBytes) + // Parse file type info from header buffer. + info, err := filetype.Match(hdrBuf) if err != nil { - return fmt.Errorf("store: error parsing content type: %s", err) + return fmt.Errorf("error parsing file type: %w", err) } - // bail if this is a type we can't process - if !supportedEmoji(contentType) { - return fmt.Errorf("store: content type %s was not valid for an emoji", contentType) + switch info.Extension { + // only supported emoji types + case "gif", "png": + + // unhandled + default: + return fmt.Errorf("unsupported emoji filetype: %s", info.Extension) } - // extract the file extension - split := strings.Split(contentType, "/") - extension := split[1] // something like 'gif' + // Recombine header bytes with remaining stream + r := io.MultiReader(bytes.NewReader(hdrBuf), rc) + + var maxSize bytesize.Size + + if p.emoji.Domain == "" { + // this is a local emoji upload + maxSize = config.GetMediaEmojiLocalMaxSize() + } else { + // this is a remote incoming emoji + maxSize = config.GetMediaEmojiRemoteMaxSize() + } + + // 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 size := bytesize.Size(sz); sz > 0 && size > maxSize { + return fmt.Errorf("given emoji size %s greater than max allowed %s", size, maxSize) + } - // set some additional fields on the emoji now that - // we know more about what the underlying image actually is var pathID string + if p.refresh { + // This is a refreshed emoji with a new + // path ID that this will be stored under. pathID = p.newPathID } else { + // This is a new emoji, simply use provided ID. pathID = p.emoji.ID } - p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), pathID, extension) - p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, pathID, extension) - p.emoji.ImageContentType = contentType - // concatenate the first bytes with the existing bytes still in the reader (thanks Mara) - readerToStore := io.MultiReader(bytes.NewBuffer(firstBytes), rc) + // Calculate emoji file path. + p.emoji.ImagePath = fmt.Sprintf( + "%s/%s/%s/%s.%s", + p.instAccID, + TypeEmoji, + SizeOriginal, + pathID, + info.Extension, + ) - var maxEmojiSize int64 - if p.emoji.Domain == "" { - maxEmojiSize = int64(config.GetMediaEmojiLocalMaxSize()) - } else { - maxEmojiSize = int64(config.GetMediaEmojiRemoteMaxSize()) - } + // This shouldn't already exist, but we do a check as it's worth logging. + if have, _ := p.manager.storage.Has(ctx, p.emoji.ImagePath); have { + log.Warnf("emoji already exists at storage path: %s", p.emoji.ImagePath) - // if we know the fileSize already, make sure it's not bigger than our limit - var checkedSize bool - if fileSize > 0 { - checkedSize = true - if fileSize > maxEmojiSize { - return fmt.Errorf("store: given emoji fileSize (%db) is larger than allowed size (%db)", fileSize, maxEmojiSize) + // Attempt to remove existing emoji at storage path (might be broken / out-of-date) + if err := p.manager.storage.Delete(ctx, p.emoji.ImagePath); err != nil { + return fmt.Errorf("error removing emoji from storage: %v", err) } } - // store this for now -- other processes can pull it out of storage as they please - if fileSize, err = putStream(ctx, p.storage, p.emoji.ImagePath, readerToStore, fileSize); err != nil { - if !errors.Is(err, storage.ErrAlreadyExists) { - return fmt.Errorf("store: error storing stream: %s", err) - } - log.Warnf("emoji %s already exists at storage path: %s", p.emoji.ID, p.emoji.ImagePath) + // Write the final image reader stream to our storage. + sz, err = p.manager.storage.PutStream(ctx, p.emoji.ImagePath, r) + if err != nil { + return fmt.Errorf("error writing emoji to storage: %w", err) } - // if we didn't know the fileSize yet, we do now, so check if we need to - if !checkedSize && fileSize > maxEmojiSize { - err = fmt.Errorf("store: discovered emoji fileSize (%db) is larger than allowed emojiRemoteMaxSize (%db), will delete from the store now", fileSize, maxEmojiSize) - log.Warn(err) - if deleteErr := p.storage.Delete(ctx, p.emoji.ImagePath); deleteErr != nil { - log.Errorf("store: error removing too-large emoji from the store: %s", deleteErr) + // Once again check size in case none was provided previously. + if size := bytesize.Size(sz); size > maxSize { + if err := p.manager.storage.Delete(ctx, p.emoji.ImagePath); err != nil { + log.Errorf("error removing too-large-emoji from storage: %v", err) } - return err + return fmt.Errorf("calculated emoji size %s greater than max allowed %s", size, maxSize) } - p.emoji.ImageFileSize = int(fileSize) - p.read = true + // Fill in remaining attachment data now it's stored. + p.emoji.ImageURL = uris.GenerateURIForAttachment( + p.instAccID, + string(TypeEmoji), + string(SizeOriginal), + pathID, + info.Extension, + ) + p.emoji.ImageContentType = info.MIME.Value + p.emoji.ImageFileSize = int(sz) + + return nil +} + +func (p *ProcessingEmoji) finish(ctx context.Context) error { + // Fetch a stream to the original file in storage. + rc, err := p.manager.storage.GetStream(ctx, p.emoji.ImagePath) + if err != nil { + return fmt.Errorf("error loading file from storage: %w", err) + } + defer rc.Close() + + // Decode the image from storage. + staticImg, err := decodeImage(rc) + if err != nil { + return fmt.Errorf("error decoding image: %w", err) + } + + // The image should be in-memory by now. + if err := rc.Close(); err != nil { + return fmt.Errorf("error closing file: %w", err) + } + + // This shouldn't already exist, but we do a check as it's worth logging. + if have, _ := p.manager.storage.Has(ctx, p.emoji.ImageStaticPath); have { + log.Warnf("static emoji already exists at storage path: %s", p.emoji.ImagePath) + + // Attempt to remove static existing emoji at storage path (might be broken / out-of-date) + if err := p.manager.storage.Delete(ctx, p.emoji.ImageStaticPath); err != nil { + return fmt.Errorf("error removing static emoji from storage: %v", err) + } + } + + // Create an emoji PNG encoder stream. + enc := staticImg.ToPNG() + + // Stream-encode the PNG static image into storage. + sz, err := p.manager.storage.PutStream(ctx, p.emoji.ImageStaticPath, enc) + if err != nil { + return fmt.Errorf("error stream-encoding static emoji to storage: %w", err) + } + + // Set written image size. + p.emoji.ImageStaticFileSize = int(sz) return nil } @@ -406,15 +415,13 @@ func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, postData P } processingEmoji := &ProcessingEmoji{ - instanceAccountID: instanceAccount.ID, - emoji: emoji, - data: data, - postData: postData, - staticState: int32(received), - database: m.db, - storage: m.storage, - refresh: refresh, - newPathID: newPathID, + instAccID: instanceAccount.ID, + emoji: emoji, + refresh: refresh, + newPathID: newPathID, + dataFn: data, + postFn: postData, + manager: m, } return processingEmoji, nil diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index 6e02ce147..4b2ef322d 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -21,387 +21,329 @@ import ( "bytes" "context" - "errors" "fmt" + "image/jpeg" "io" - "strings" "sync" - "sync/atomic" "time" + "github.com/disintegration/imaging" + "github.com/h2non/filetype" terminator "github.com/superseriousbusiness/exif-terminator" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/uris" ) // ProcessingMedia represents a piece of media that is currently being processed. It exposes // various functions for retrieving data from the process. type ProcessingMedia struct { - mu sync.Mutex - - /* - below fields should be set on newly created media; - attachment will be updated incrementally as media goes through processing - */ - - attachment *gtsmodel.MediaAttachment - data DataFunc - postData PostDataCallbackFunc - read bool // bool indicating that data function has been triggered already - - thumbState int32 // the processing state of the media thumbnail - fullSizeState int32 // the processing state of the full-sized media - - /* - below pointers to database and storage are maintained so that - the media can store and update itself during processing steps - */ - - database db.DB - storage *storage.Driver - - err error // error created during processing, if any - - // track whether this media has already been put in the databse - insertedInDB bool - - // true if this is a recache, false if it's brand new media - recache bool + media *gtsmodel.MediaAttachment // processing media attachment details + recache bool // recaching existing (uncached) media + dataFn DataFunc // load-data function, returns media stream + postFn PostDataCallbackFunc // post data callback function + err error // error encountered during processing + manager *manager // manager instance (access to db / storage) + once sync.Once // once ensures processing only occurs once } // AttachmentID returns the ID of the underlying media attachment without blocking processing. func (p *ProcessingMedia) AttachmentID() string { - return p.attachment.ID + return p.media.ID // immutable, safe outside mutex. } // LoadAttachment blocks until the thumbnail and fullsize content // has been processed, and then returns the completed attachment. func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - p.mu.Lock() - defer p.mu.Unlock() - - if err := p.store(ctx); err != nil { - return nil, err - } - - if err := p.loadFullSize(ctx); err != nil { - return nil, err - } - - if err := p.loadThumb(ctx); err != nil { - return nil, err - } - - if !p.insertedInDB { - if p.recache { - // This is an existing media attachment we're recaching, so only need to update it - if err := p.database.UpdateByID(ctx, p.attachment, p.attachment.ID); err != nil { - return nil, err - } - } else { - // This is a new media attachment we're caching for first time - if err := p.database.Put(ctx, p.attachment); err != nil { - return nil, err - } - } - - // Mark this as stored in DB - p.insertedInDB = true - } - - log.Tracef("finished loading attachment %s", p.attachment.URL) - return p.attachment, nil -} - -// Finished returns true if processing has finished for both the thumbnail -// and full fized version of this piece of media. -func (p *ProcessingMedia) Finished() bool { - return atomic.LoadInt32(&p.thumbState) == int32(complete) && atomic.LoadInt32(&p.fullSizeState) == int32(complete) -} - -func (p *ProcessingMedia) loadThumb(ctx context.Context) error { - thumbState := atomic.LoadInt32(&p.thumbState) - switch processState(thumbState) { - case received: - // we haven't processed a thumbnail for this media yet so do it now - // check if we need to create a blurhash or if there's already one set - var createBlurhash bool - if p.attachment.Blurhash == "" { - // no blurhash created yet - createBlurhash = true - } - - var ( - thumb *mediaMeta - err error - ) - switch ct := p.attachment.File.ContentType; ct { - case mimeImageJpeg, mimeImagePng, mimeImageWebp, mimeImageGif: - // thumbnail the image from the original stored full size version - stored, err := p.storage.GetStream(ctx, p.attachment.File.Path) - if err != nil { - p.err = fmt.Errorf("loadThumb: error fetching file from storage: %s", err) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - - thumb, err = deriveThumbnailFromImage(stored, ct, createBlurhash) - - // try to close the stored stream we had open, no matter what - if closeErr := stored.Close(); closeErr != nil { - log.Errorf("error closing stream: %s", closeErr) - } - - // now check if we managed to get a thumbnail - if err != nil { - p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - case mimeVideoMp4: - // create a generic thumbnail based on video height + width - thumb, err = deriveThumbnailFromVideo(p.attachment.FileMeta.Original.Height, p.attachment.FileMeta.Original.Width) - if err != nil { - p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - default: - p.err = fmt.Errorf("loadThumb: content type %s not a processible image type", ct) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - - // put the thumbnail in storage - if err := p.storage.Put(ctx, p.attachment.Thumbnail.Path, thumb.small); err != nil && err != storage.ErrAlreadyExists { - p.err = fmt.Errorf("loadThumb: error storing thumbnail: %s", err) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - - // set appropriate fields on the attachment based on the thumbnail we derived - if createBlurhash { - p.attachment.Blurhash = thumb.blurhash - } - p.attachment.FileMeta.Small = gtsmodel.Small{ - Width: thumb.width, - Height: thumb.height, - Size: thumb.size, - Aspect: thumb.aspect, - } - p.attachment.Thumbnail.FileSize = len(thumb.small) - - // we're done processing the thumbnail! - atomic.StoreInt32(&p.thumbState, int32(complete)) - log.Tracef("finished processing thumbnail for attachment %s", p.attachment.URL) - fallthrough - case complete: - return nil - case errored: - return p.err - } - - return fmt.Errorf("loadThumb: thumbnail processing status %d unknown", p.thumbState) -} - -func (p *ProcessingMedia) loadFullSize(ctx context.Context) error { - fullSizeState := atomic.LoadInt32(&p.fullSizeState) - switch processState(fullSizeState) { - case received: + // only process once. + p.once.Do(func() { var err error - var decoded *mediaMeta - - // stream the original file out of storage... - stored, err := p.storage.GetStream(ctx, p.attachment.File.Path) - if err != nil { - p.err = fmt.Errorf("loadFullSize: error fetching file from storage: %s", err) - atomic.StoreInt32(&p.fullSizeState, int32(errored)) - return p.err - } defer func() { - if err := stored.Close(); err != nil { - log.Errorf("loadFullSize: error closing stored full size: %s", err) + if r := recover(); r != nil { + if err != nil { + rOld := r // wrap the panic so we don't lose existing returned error + r = fmt.Errorf("panic occured after error %q: %v", err.Error(), rOld) + } + + // Catch any panics and wrap as error. + err = fmt.Errorf("caught panic: %v", r) + } + + if err != nil { + // Store error. + p.err = err } }() - // decode the image - ct := p.attachment.File.ContentType - switch ct { - case mimeImageJpeg, mimeImagePng, mimeImageWebp: - decoded, err = decodeImage(stored, ct) - case mimeImageGif: - decoded, err = decodeGif(stored) - case mimeVideoMp4: - decoded, err = decodeVideo(stored, ct) - default: - err = fmt.Errorf("loadFullSize: content type %s not a processible image type", ct) + // Attempt to store media and calculate + // full-size media attachment details. + if err = p.store(ctx); err != nil { + return } - if err != nil { - p.err = err - atomic.StoreInt32(&p.fullSizeState, int32(errored)) - return p.err + // Finish processing by reloading media into + // memory to get dimension and generate a thumb. + if err = p.finish(ctx); err != nil { + return } - // set appropriate fields on the attachment based on the image we derived - - // generic fields - p.attachment.File.UpdatedAt = time.Now() - p.attachment.FileMeta.Original = gtsmodel.Original{ - Width: decoded.width, - Height: decoded.height, - Size: decoded.size, - Aspect: decoded.aspect, + if p.recache { + // Existing attachment we're recaching, so only need to update. + err = p.manager.db.UpdateByID(ctx, p.media, p.media.ID) + return } - // nullable fields - if decoded.duration != 0 { - i := decoded.duration - p.attachment.FileMeta.Original.Duration = &i - } - if decoded.framerate != 0 { - i := decoded.framerate - p.attachment.FileMeta.Original.Framerate = &i - } - if decoded.bitrate != 0 { - i := decoded.bitrate - p.attachment.FileMeta.Original.Bitrate = &i - } + // New attachment, first time caching. + err = p.manager.db.Put(ctx, p.media) + return //nolint shutup linter i like this here + }) - // we're done processing the full-size image - p.attachment.Processing = gtsmodel.ProcessingStatusProcessed - atomic.StoreInt32(&p.fullSizeState, int32(complete)) - log.Tracef("finished processing full size image for attachment %s", p.attachment.URL) - fallthrough - case complete: - return nil - case errored: - return p.err + if p.err != nil { + return nil, p.err } - return fmt.Errorf("loadFullSize: full size processing status %d unknown", p.fullSizeState) + return p.media, nil } // store calls the data function attached to p if it hasn't been called yet, // and updates the underlying attachment fields as necessary. It will then stream // bytes from p's reader directly into storage so that it can be retrieved later. func (p *ProcessingMedia) store(ctx context.Context) error { - // check if we've already done this and bail early if we have - if p.read { - return nil - } - - // execute the data function to get the readcloser out of it - rc, fileSize, err := p.data(ctx) - if err != nil { - return fmt.Errorf("store: error executing data function: %s", err) - } - - // defer closing the reader when we're done with it defer func() { + if p.postFn == nil { + return + } + + // ensure post callback gets called. + if err := p.postFn(ctx); err != nil { + log.Errorf("error executing postdata function: %v", err) + } + }() + + // Load media from provided data fun + rc, sz, err := p.dataFn(ctx) + if err != nil { + return fmt.Errorf("error executing data function: %w", err) + } + + defer func() { + // Ensure data reader gets closed on return. if err := rc.Close(); err != nil { - log.Errorf("store: error closing readcloser: %s", err) + log.Errorf("error closing data reader: %v", err) } }() - // execute the postData function no matter what happens - defer func() { - if p.postData != nil { - if err := p.postData(ctx); err != nil { - log.Errorf("store: error executing postData: %s", err) - } - } - }() + // Byte buffer to read file header into. + // See: https://en.wikipedia.org/wiki/File_format#File_header + // and https://github.com/h2non/filetype + hdrBuf := make([]byte, 261) - // extract no more than 261 bytes from the beginning of the file -- this is the header - firstBytes := make([]byte, maxFileHeaderBytes) - if _, err := rc.Read(firstBytes); err != nil { - return fmt.Errorf("store: error reading initial %d bytes: %s", maxFileHeaderBytes, err) + // Read the first 261 header bytes into buffer. + if _, err := io.ReadFull(rc, hdrBuf); err != nil { + return fmt.Errorf("error reading incoming media: %w", err) } - // now we have the file header we can work out the content type from it - contentType, err := parseContentType(firstBytes) + // Parse file type info from header buffer. + info, err := filetype.Match(hdrBuf) if err != nil { - return fmt.Errorf("store: error parsing content type: %s", err) + return fmt.Errorf("error parsing file type: %w", err) } - // bail if this is a type we can't process - if !supportedAttachment(contentType) { - return fmt.Errorf("store: media type %s not (yet) supported", contentType) - } + // Recombine header bytes with remaining stream + r := io.MultiReader(bytes.NewReader(hdrBuf), rc) - // extract the file extension - split := strings.Split(contentType, "/") - if len(split) != 2 { - return fmt.Errorf("store: content type %s was not valid", contentType) - } - extension := split[1] // something like 'jpeg' + switch info.Extension { + case "mp4": + p.media.Type = gtsmodel.FileTypeVideo - // concatenate the cleaned up first bytes with the existing bytes still in the reader (thanks Mara) - multiReader := io.MultiReader(bytes.NewBuffer(firstBytes), rc) + case "gif": + p.media.Type = gtsmodel.FileTypeImage - // use the extension to derive the attachment type - // and, while we're in here, clean up exif data from - // the image if we already know the fileSize - var readerToStore io.Reader - switch extension { - case mimeGif: - p.attachment.Type = gtsmodel.FileTypeImage - // nothing to terminate, we can just store the multireader - readerToStore = multiReader - case mimeJpeg, mimePng, mimeWebp: - p.attachment.Type = gtsmodel.FileTypeImage - if fileSize > 0 { - terminated, err := terminator.Terminate(multiReader, int(fileSize), extension) + case "jpg", "jpeg", "png", "webp": + p.media.Type = gtsmodel.FileTypeImage + if sz > 0 { + // A file size was provided so we can clean exif data from image. + r, err = terminator.Terminate(r, int(sz), info.Extension) if err != nil { - return fmt.Errorf("store: exif error: %s", err) + return fmt.Errorf("error cleaning exif data: %w", err) } - defer func() { - if closer, ok := terminated.(io.Closer); ok { - if err := closer.Close(); err != nil { - log.Errorf("store: error closing terminator reader: %s", err) - } - } - }() - // store the exif-terminated version of what was in the multireader - readerToStore = terminated - } else { - // can't terminate if we don't know the file size, so just store the multiReader - readerToStore = multiReader } - case mimeMp4: - p.attachment.Type = gtsmodel.FileTypeVideo - // nothing to terminate, we can just store the multireader - readerToStore = multiReader + default: - return fmt.Errorf("store: couldn't process %s", extension) + return fmt.Errorf("unsupported file type: %s", info.Extension) } - // now set some additional fields on the attachment since - // we know more about what the underlying media actually is - p.attachment.URL = uris.GenerateURIForAttachment(p.attachment.AccountID, string(TypeAttachment), string(SizeOriginal), p.attachment.ID, extension) - p.attachment.File.ContentType = contentType - p.attachment.File.Path = fmt.Sprintf("%s/%s/%s/%s.%s", p.attachment.AccountID, TypeAttachment, SizeOriginal, p.attachment.ID, extension) + // Calculate attachment file path. + p.media.File.Path = fmt.Sprintf( + "%s/%s/%s/%s.%s", + p.media.AccountID, + TypeAttachment, + SizeOriginal, + p.media.ID, + info.Extension, + ) - // store this for now -- other processes can pull it out of storage as they please - if fileSize, err = putStream(ctx, p.storage, p.attachment.File.Path, readerToStore, fileSize); err != nil { - if !errors.Is(err, storage.ErrAlreadyExists) { - return fmt.Errorf("store: error storing stream: %s", err) + // This shouldn't already exist, but we do a check as it's worth logging. + if have, _ := p.manager.storage.Has(ctx, p.media.File.Path); have { + log.Warnf("media already exists at storage path: %s", p.media.File.Path) + + // Attempt to remove existing media at storage path (might be broken / out-of-date) + if err := p.manager.storage.Delete(ctx, p.media.File.Path); err != nil { + return fmt.Errorf("error removing media from storage: %v", err) } - log.Warnf("attachment %s already exists at storage path: %s", p.attachment.ID, p.attachment.File.Path) } - cached := true - p.attachment.Cached = &cached - p.attachment.File.FileSize = int(fileSize) - p.read = true + // Write the final image reader stream to our storage. + sz, err = p.manager.storage.PutStream(ctx, p.media.File.Path, r) + if err != nil { + return fmt.Errorf("error writing media to storage: %w", err) + } + + // Set written image size. + p.media.File.FileSize = int(sz) + + // Fill in remaining attachment data now it's stored. + p.media.URL = uris.GenerateURIForAttachment( + p.media.AccountID, + string(TypeAttachment), + string(SizeOriginal), + p.media.ID, + info.Extension, + ) + p.media.File.ContentType = info.MIME.Value + cached := true + p.media.Cached = &cached + + return nil +} + +func (p *ProcessingMedia) finish(ctx context.Context) error { + // Fetch a stream to the original file in storage. + rc, err := p.manager.storage.GetStream(ctx, p.media.File.Path) + if err != nil { + return fmt.Errorf("error loading file from storage: %w", err) + } + defer rc.Close() + + var fullImg *gtsImage + + switch p.media.File.ContentType { + // .jpeg, .gif, .webp image type + case mimeImageJpeg, mimeImageGif, mimeImageWebp: + fullImg, err = decodeImage(rc, imaging.AutoOrientation(true)) + if err != nil { + return fmt.Errorf("error decoding image: %w", err) + } + + // .png image (requires ancillary chunk stripping) + case mimeImagePng: + fullImg, err = decodeImage(&PNGAncillaryChunkStripper{ + Reader: rc, + }, imaging.AutoOrientation(true)) + if err != nil { + return fmt.Errorf("error decoding image: %w", err) + } + + // .mp4 video type + case mimeVideoMp4: + video, err := decodeVideoFrame(rc) + if err != nil { + return fmt.Errorf("error decoding video: %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 + } + + // The image should be in-memory by now. + if err := rc.Close(); err != nil { + return fmt.Errorf("error closing file: %w", err) + } + + // Set full-size dimensions in attachment info. + p.media.FileMeta.Original.Width = int(fullImg.Width()) + p.media.FileMeta.Original.Height = int(fullImg.Height()) + p.media.FileMeta.Original.Size = int(fullImg.Size()) + p.media.FileMeta.Original.Aspect = fullImg.AspectRatio() + + // Calculate attachment thumbnail file path + p.media.Thumbnail.Path = fmt.Sprintf( + "%s/%s/%s/%s.jpg", + p.media.AccountID, + TypeAttachment, + SizeSmall, + p.media.ID, + ) + + // Get smaller thumbnail image + thumbImg := fullImg.Thumbnail() + + // Garbage collector, you may + // now take our large son. + fullImg = nil + + // Blurhash needs generating from thumb. + hash, err := thumbImg.Blurhash() + if err != nil { + return fmt.Errorf("error generating blurhash: %w", err) + } + + // Set the attachment blurhash. + p.media.Blurhash = hash + + // This shouldn't already exist, but we do a check as it's worth logging. + if have, _ := p.manager.storage.Has(ctx, p.media.Thumbnail.Path); have { + log.Warnf("thumbnail already exists at storage path: %s", p.media.Thumbnail.Path) + + // Attempt to remove existing thumbnail at storage path (might be broken / out-of-date) + if err := p.manager.storage.Delete(ctx, p.media.Thumbnail.Path); err != nil { + return fmt.Errorf("error removing thumbnail from storage: %v", err) + } + } + + // Create a thumbnail JPEG encoder stream. + enc := thumbImg.ToJPEG(&jpeg.Options{ + Quality: 70, // enough for a thumbnail. + }) + + // Stream-encode the JPEG thumbnail image into storage. + sz, err := p.manager.storage.PutStream(ctx, p.media.Thumbnail.Path, enc) + if err != nil { + return fmt.Errorf("error stream-encoding thumbnail to storage: %w", err) + } + + // Fill in remaining thumbnail now it's stored + p.media.Thumbnail.ContentType = mimeImageJpeg + p.media.Thumbnail.URL = uris.GenerateURIForAttachment( + p.media.AccountID, + string(TypeAttachment), + string(SizeSmall), + p.media.ID, + "jpg", // always jpeg + ) + + // Set thumbnail dimensions in attachment info. + p.media.FileMeta.Small = gtsmodel.Small{ + Width: int(thumbImg.Width()), + Height: int(thumbImg.Height()), + Size: int(thumbImg.Size()), + Aspect: thumbImg.AspectRatio(), + } + + // Set written image size. + p.media.Thumbnail.FileSize = int(sz) + + // Finally set the attachment as processed and update time. + p.media.Processing = gtsmodel.ProcessingStatusProcessed + p.media.File.UpdatedAt = time.Now() - log.Tracef("finished storing initial data for attachment %s", p.attachment.URL) return nil } @@ -411,19 +353,6 @@ func (m *manager) preProcessMedia(ctx context.Context, data DataFunc, postData P return nil, err } - file := gtsmodel.File{ - Path: "", // we don't know yet because it depends on the uncalled DataFunc - ContentType: "", // we don't know yet because it depends on the uncalled DataFunc - UpdatedAt: time.Now(), - } - - thumbnail := gtsmodel.Thumbnail{ - URL: uris.GenerateURIForAttachment(accountID, string(TypeAttachment), string(SizeSmall), id, mimeJpeg), // all thumbnails are encoded as jpeg, - Path: fmt.Sprintf("%s/%s/%s/%s.%s", accountID, TypeAttachment, SizeSmall, id, mimeJpeg), // all thumbnails are encoded as jpeg, - ContentType: mimeImageJpeg, - UpdatedAt: time.Now(), - } - avatar := false header := false cached := false @@ -443,8 +372,8 @@ func (m *manager) preProcessMedia(ctx context.Context, data DataFunc, postData P ScheduledStatusID: "", Blurhash: "", Processing: gtsmodel.ProcessingStatusReceived, - File: file, - Thumbnail: thumbnail, + File: gtsmodel.File{UpdatedAt: time.Now()}, + Thumbnail: gtsmodel.Thumbnail{UpdatedAt: time.Now()}, Avatar: &avatar, Header: &header, Cached: &cached, @@ -495,34 +424,28 @@ func (m *manager) preProcessMedia(ctx context.Context, data DataFunc, postData P } processingMedia := &ProcessingMedia{ - attachment: attachment, - data: data, - postData: postData, - thumbState: int32(received), - fullSizeState: int32(received), - database: m.db, - storage: m.storage, + media: attachment, + dataFn: data, + postFn: postData, + manager: m, } return processingMedia, nil } -func (m *manager) preProcessRecache(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, attachmentID string) (*ProcessingMedia, error) { - // get the existing attachment - attachment, err := m.db.GetAttachmentByID(ctx, attachmentID) +func (m *manager) preProcessRecache(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, id string) (*ProcessingMedia, error) { + // get the existing attachment from database. + attachment, err := m.db.GetAttachmentByID(ctx, id) if err != nil { return nil, err } processingMedia := &ProcessingMedia{ - attachment: attachment, - data: data, - postData: postData, - thumbState: int32(received), - fullSizeState: int32(received), - database: m.db, - storage: m.storage, - recache: true, // indicate it's a recache + media: attachment, + dataFn: data, + postFn: postData, + manager: m, + recache: true, // indicate it's a recache } return processingMedia, nil diff --git a/internal/media/pruneorphaned_test.go b/internal/media/pruneorphaned_test.go index 2d3ed5a31..52976b51b 100644 --- a/internal/media/pruneorphaned_test.go +++ b/internal/media/pruneorphaned_test.go @@ -39,7 +39,7 @@ func (suite *PruneOrphanedTestSuite) TestPruneOrphanedDry() { } pandaPath := "01GJQJ1YD9QCHCE12GG0EYHVNW/attachments/original/01GJQJ2AYM1VKSRW96YVAJ3NK3.gif" - if err := suite.storage.PutStream(context.Background(), pandaPath, bytes.NewBuffer(b)); err != nil { + if _, err := suite.storage.PutStream(context.Background(), pandaPath, bytes.NewBuffer(b)); err != nil { panic(err) } @@ -62,7 +62,7 @@ func (suite *PruneOrphanedTestSuite) TestPruneOrphanedMoist() { } pandaPath := "01GJQJ1YD9QCHCE12GG0EYHVNW/attachments/original/01GJQJ2AYM1VKSRW96YVAJ3NK3.gif" - if err := suite.storage.PutStream(context.Background(), pandaPath, bytes.NewBuffer(b)); err != nil { + if _, err := suite.storage.PutStream(context.Background(), pandaPath, bytes.NewBuffer(b)); err != nil { panic(err) } diff --git a/internal/media/pruneremote_test.go b/internal/media/pruneremote_test.go index 258aa20ca..51521422c 100644 --- a/internal/media/pruneremote_test.go +++ b/internal/media/pruneremote_test.go @@ -87,7 +87,7 @@ func (suite *PruneRemoteTestSuite) TestPruneAndRecache() { // now recache the image.... data := func(_ context.Context) (io.ReadCloser, int64, error) { // load bytes from a test image - b, err := os.ReadFile("../../testrig/media/thoughtsofdog-original.jpeg") + b, err := os.ReadFile("../../testrig/media/thoughtsofdog-original.jpg") if err != nil { panic(err) } diff --git a/internal/media/test/longer-mp4-thumbnail.jpg b/internal/media/test/longer-mp4-thumbnail.jpg index e775349502b2f0479673e26e2e75b7cdbb48316e..076db8251eeae0a63d1c3cb9c7207abbfd9e94bc 100644 GIT binary patch literal 2897 zcmex=KU|?coW@chxW@Tkz0jjJ8$}zAA zvI;30I#U-U>$dGXcJ4ZK_{h;?$4{I*b?NeztJkjIxOwa0qsLF4K70P+<*SdM zK7aZ8?fZ|PzZil3g8_(#ko+SE^bZpY3o{El$UlrsuS~;l_iU%Emz-M3agxa*3&!JXHM%@)Fob#CfQREFZx=1ozMXTMRtRjKBzE7G$tz l_&7^rQ3KZ~9u0%hG%%V5M$^D(8W>FjqiJ9Sr2+f@Hvy(B!Yu#* literal 3784 zcmex=>Qk2+yV?tjLghTEX=H|EG$5kwLm!r7C}}aMMFn6;lM<8r9u&-#)%6#l$|yn z6b-ugLB%+!sELzHOk6@zN>xo=LsQGd)Xdz%(#qMz)y>_*(xBk z{lmnuTY(KIlc21e7s aXc`zz1EXnRG!2ZVfzdQT!!#iO|0V#We?V#g diff --git a/internal/media/test/test-jpeg-thumbnail.jpg b/internal/media/test/test-jpeg-thumbnail.jpg index 80170e7c846fb6a8e72186fb078ccd593abf4542..c11569fe6972305481e5f4fcd020d951345a4cd8 100644 GIT binary patch literal 20973 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 PPr9dl-sOt$|A{(O)2 literal 22858 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�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 diff --git a/internal/media/test/test-mp4-thumbnail.jpg b/internal/media/test/test-mp4-thumbnail.jpg index 8bfdf15406ef94da62d59659bf4af36ed537849c..6d33c1b78ee8f7abcce7f9367f4c730dff55026c 100644 GIT binary patch literal 1913 zcmex=X!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VCCQY6)b=ve9GiNPY zykzOJeA&aSFc^aar4&0M~|O8efIpt%U2&i zeg5+G+xH(oe=#yJL%ahdAs#~Vk08)LOe`$SEbJivFfx?`F|!~GtD+&BkYgZwVxh2- zQ6q_0Zp(e6?1osf!KmTtr@Gvt1BaB&) f!JgscER97CT%&k23`WzyXc`zz131%w{r{T)nqPd4 literal 1912 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 maxFileHeaderBytes { - return "", fmt.Errorf("parseContentType requires %d bytes max, we got %d", maxFileHeaderBytes, fhLength) - } - - kind, err := filetype.Match(fileHeader) - if err != nil { - return "", err - } - - if kind == filetype.Unknown { - return "", errors.New("filetype unknown") - } - - return kind.MIME.Value, nil -} - -// supportedAttachment checks mime type of an attachment against a -// slice of accepted types, and returns True if the mime type is accepted. -func supportedAttachment(mimeType string) bool { - for _, accepted := range AllSupportedMIMETypes() { - if mimeType == accepted { - return true - } - } - return false -} - -// supportedEmoji checks that the content type is image/png or image/gif -- the only types supported for emoji. -func supportedEmoji(mimeType string) bool { - acceptedEmojiTypes := []string{ - mimeImageGif, - mimeImagePng, - } - for _, accepted := range acceptedEmojiTypes { - if mimeType == accepted { - return true - } - } - return false +var SupportedEmojiMIMETypes = []string{ + mimeImageGif, + mimeImagePng, } // ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized @@ -127,31 +77,3 @@ func (l *logrusWrapper) Info(msg string, keysAndValues ...interface{}) { func (l *logrusWrapper) Error(err error, msg string, keysAndValues ...interface{}) { log.Error("media manager cron logger: ", err, msg, keysAndValues) } - -// lengthReader wraps a reader and reads the length of total bytes written as it goes. -type lengthReader struct { - source io.Reader - length int64 -} - -func (r *lengthReader) Read(b []byte) (int, error) { - n, err := r.source.Read(b) - r.length += int64(n) - return n, err -} - -// putStream either puts a file with a known fileSize into storage directly, and returns the -// fileSize unchanged, or it wraps the reader with a lengthReader and returns the discovered -// fileSize. -func putStream(ctx context.Context, storage *storage.Driver, key string, r io.Reader, fileSize int64) (int64, error) { - if fileSize > 0 { - return fileSize, storage.PutStream(ctx, key, r) - } - - lr := &lengthReader{ - source: r, - } - - err := storage.PutStream(ctx, key, lr) - return lr.length, err -} diff --git a/internal/media/video.go b/internal/media/video.go index bd624559b..bffdfbbba 100644 --- a/internal/media/video.go +++ b/internal/media/video.go @@ -19,63 +19,55 @@ package media import ( - "bytes" "fmt" - "image" - "image/color" - "image/draw" - "image/jpeg" "io" "os" "github.com/abema/go-mp4" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/log" ) -var thumbFill = color.RGBA{42, 43, 47, 0} // the color to fill video thumbnails with +type gtsVideo struct { + frame *gtsImage + duration float32 // in seconds + bitrate uint64 + framerate float32 +} -func decodeVideo(r io.Reader, contentType string) (*mediaMeta, error) { +// 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) { // We'll need a readseeker to decode the video. We can get a readseeker // without burning too much mem by first copying the reader into a temp file. // First create the file in the temporary directory... - tempFile, err := os.CreateTemp(os.TempDir(), "gotosocial-") + tmp, err := os.CreateTemp(os.TempDir(), "gotosocial-") if err != nil { - return nil, fmt.Errorf("could not create temporary file while decoding video: %w", err) + return nil, err } - tempFileName := tempFile.Name() - // Make sure to clean up the temporary file when we're done with it defer func() { - if err := tempFile.Close(); err != nil { - log.Errorf("could not close file %s: %s", tempFileName, err) - } - if err := os.Remove(tempFileName); err != nil { - log.Errorf("could not remove file %s: %s", tempFileName, err) - } + tmp.Close() + os.Remove(tmp.Name()) }() // Now copy the entire reader we've been provided into the // temporary file; we won't use the reader again after this. - if _, err := io.Copy(tempFile, r); err != nil { - return nil, fmt.Errorf("could not copy video reader into temporary file %s: %w", tempFileName, err) + if _, err := io.Copy(tmp, r); err != nil { + return nil, err } - var ( - width int - height int - duration float32 - framerate float32 - bitrate uint64 - ) - // 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(tempFile) + info, err := mp4.Probe(tmp) if err != nil { - return nil, fmt.Errorf("could not probe temporary video file %s: %w", tempFileName, err) + return nil, fmt.Errorf("error probing tmp file %s: %w", tmp.Name(), err) } + var ( + width int + height int + video gtsVideo + ) + for _, tr := range info.Tracks { if tr.AVC == nil { continue @@ -89,72 +81,42 @@ func decodeVideo(r io.Reader, contentType string) (*mediaMeta, error) { height = h } - if br := tr.Samples.GetBitrate(tr.Timescale); br > bitrate { - bitrate = br - } else if br := info.Segments.GetBitrate(tr.TrackID, tr.Timescale); br > bitrate { - bitrate = br + if br := tr.Samples.GetBitrate(tr.Timescale); br > video.bitrate { + video.bitrate = br + } else if br := info.Segments.GetBitrate(tr.TrackID, tr.Timescale); br > video.bitrate { + video.bitrate = br } - if d := float32(tr.Duration) / float32(tr.Timescale); d > duration { - duration = d - framerate = float32(len(tr.Samples)) / duration + if d := float64(tr.Duration) / float64(tr.Timescale); d > float64(video.duration) { + video.framerate = float32(len(tr.Samples)) / float32(d) + video.duration = float32(d) } } - var errs gtserror.MultiError + // Check for empty video metadata. + var empty []string if width == 0 { - errs = append(errs, "video width could not be discovered") + empty = append(empty, "width") } - if height == 0 { - errs = append(errs, "video height could not be discovered") + 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) } - if duration == 0 { - errs = append(errs, "video duration could not be discovered") - } + // Create new empty "frame" image. + // TODO: decode frame from video file. + video.frame = blankImage(width, height) - if framerate == 0 { - errs = append(errs, "video framerate could not be discovered") - } - - if bitrate == 0 { - errs = append(errs, "video bitrate could not be discovered") - } - - if errs != nil { - return nil, errs.Combine() - } - - return &mediaMeta{ - width: width, - height: height, - duration: duration, - framerate: framerate, - bitrate: bitrate, - size: height * width, - aspect: float32(width) / float32(height), - }, nil -} - -func deriveThumbnailFromVideo(height int, width int) (*mediaMeta, error) { - // 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{thumbFill}, image.Point{}, draw.Src) - - // we can get away with using extremely poor quality for this monocolor thumbnail - out := &bytes.Buffer{} - if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 1}); err != nil { - return nil, fmt.Errorf("error encoding video thumbnail: %w", err) - } - - return &mediaMeta{ - width: width, - height: height, - size: width * height, - aspect: float32(width) / float32(height), - small: out.Bytes(), - }, nil + return &video, nil } diff --git a/internal/processing/account/getrss_test.go b/internal/processing/account/getrss_test.go index f9fb1accb..6c699abae 100644 --- a/internal/processing/account/getrss_test.go +++ b/internal/processing/account/getrss_test.go @@ -40,7 +40,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { fmt.Println(feed) - suite.Equal("\n \n Posts from @admin@localhost:8080\n http://localhost:8080/@admin\n Posts from @admin@localhost:8080\n Wed, 20 Oct 2021 12:36:45 +0000\n Wed, 20 Oct 2021 12:36:45 +0000\n \n open to see some puppies\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n @admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"\n \n @admin@localhost:8080\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n Wed, 20 Oct 2021 12:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n hello world! #welcome ! first post on the instance :rainbow: !\n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n @admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"\n !]]>\n @admin@localhost:8080\n \n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n Wed, 20 Oct 2021 11:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n", feed) + suite.Equal("\n \n Posts from @admin@localhost:8080\n http://localhost:8080/@admin\n Posts from @admin@localhost:8080\n Wed, 20 Oct 2021 12:36:45 +0000\n Wed, 20 Oct 2021 12:36:45 +0000\n \n open to see some puppies\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n @admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"\n \n @admin@localhost:8080\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n Wed, 20 Oct 2021 12:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n hello world! #welcome ! first post on the instance :rainbow: !\n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n @admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"\n !]]>\n @admin@localhost:8080\n \n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n Wed, 20 Oct 2021 11:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n", feed) } func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { @@ -53,7 +53,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { fmt.Println(feed) - suite.Equal("\n \n Posts from @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n Posts from @the_mighty_zork@localhost:8080\n Wed, 20 Oct 2021 10:40:37 +0000\n Wed, 20 Oct 2021 10:40:37 +0000\n \n http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg\n Avatar for @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n \n \n introduction post\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n @the_mighty_zork@localhost:8080 made a new post: "hello everyone!"\n \n @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n Wed, 20 Oct 2021 10:40:37 +0000\n http://localhost:8080/@the_mighty_zork/feed.rss\n \n \n", feed) + suite.Equal("\n \n Posts from @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n Posts from @the_mighty_zork@localhost:8080\n Wed, 20 Oct 2021 10:40:37 +0000\n Wed, 20 Oct 2021 10:40:37 +0000\n \n http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg\n Avatar for @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n \n \n introduction post\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n @the_mighty_zork@localhost:8080 made a new post: "hello everyone!"\n \n @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n Wed, 20 Oct 2021 10:40:37 +0000\n http://localhost:8080/@the_mighty_zork/feed.rss\n \n \n", feed) } func TestGetRSSTestSuite(t *testing.T) { diff --git a/internal/processing/admin/updateemoji.go b/internal/processing/admin/updateemoji.go index 25759ce1a..370e6e27f 100644 --- a/internal/processing/admin/updateemoji.go +++ b/internal/processing/admin/updateemoji.go @@ -90,9 +90,8 @@ func (p *processor) emojiUpdateCopy(ctx context.Context, emoji *gtsmodel.Emoji, newEmojiURI := uris.GenerateURIForEmoji(newEmojiID) data := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) { - // 'copy' the emoji by pulling the existing one out of storage - i, err := p.storage.GetStream(ctx, emoji.ImagePath) - return i, int64(emoji.ImageFileSize), err + rc, err := p.storage.GetStream(ctx, emoji.ImagePath) + return rc, int64(emoji.ImageFileSize), err } var ai *media.AdditionalEmojiInfo diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 14e031e52..d5f74926a 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -28,7 +28,6 @@ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/iotools" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/uris" @@ -99,6 +98,54 @@ func (p *processor) getAttachmentContent(ctx context.Context, requestingAccount return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, owningAccountID)) } + if !*a.Cached { + // if we don't have it cached, then we can assume two things: + // 1. this is remote media, since local media should never be uncached + // 2. we need to fetch it again using a transport and the media manager + remoteMediaIRI, err := url.Parse(a.RemoteURL) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %s", a.RemoteURL, err)) + } + + // use an empty string as requestingUsername to use the instance account, unless the request for this + // media has been http signed, then use the requesting account to make the request to remote server + var requestingUsername string + if requestingAccount != nil { + requestingUsername = requestingAccount.Username + } + + // Pour one out for tobi's original streamed recache + // (streaming data both to the client and storage). + // Gone and forever missed <3 + // + // [ + // the reason it was removed was because a slow + // client connection could hold open a storage + // recache operation, and so holding open a media + // worker worker. + // ] + + dataFn := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + t, err := p.transportController.NewTransportForUsername(innerCtx, requestingUsername) + if err != nil { + return nil, 0, err + } + return t.DereferenceMedia(transport.WithFastfail(innerCtx), remoteMediaIRI) + } + + // Start recaching this media with the prepared data function. + processingMedia, err := p.mediaManager.RecacheMedia(ctx, dataFn, nil, wantedMediaID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %s", err)) + } + + // Load attachment and block until complete + a, err = processingMedia.LoadAttachment(ctx) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %s", err)) + } + } + // get file information from the attachment depending on the requested media size switch mediaSize { case media.SizeOriginal: @@ -113,121 +160,8 @@ func (p *processor) getAttachmentContent(ctx context.Context, requestingAccount return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) } - // if we have the media cached on our server already, we can now simply return it from storage - if *a.Cached { - return p.retrieveFromStorage(ctx, storagePath, attachmentContent) - } - - // if we don't have it cached, then we can assume two things: - // 1. this is remote media, since local media should never be uncached - // 2. we need to fetch it again using a transport and the media manager - remoteMediaIRI, err := url.Parse(a.RemoteURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %s", a.RemoteURL, err)) - } - - // use an empty string as requestingUsername to use the instance account, unless the request for this - // media has been http signed, then use the requesting account to make the request to remote server - var requestingUsername string - if requestingAccount != nil { - requestingUsername = requestingAccount.Username - } - - var data media.DataFunc - - if mediaSize == media.SizeSmall { - // if it's the thumbnail that's requested then the user will have to wait a bit while we process the - // large version and derive a thumbnail from it, so use the normal recaching procedure: fetch the media, - // process it, then return the thumbnail data - data = func(innerCtx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(innerCtx, requestingUsername) - if err != nil { - return nil, 0, err - } - return t.DereferenceMedia(transport.WithFastfail(innerCtx), remoteMediaIRI) - } - } else { - // if it's the full-sized version being requested, we can cheat a bit by streaming data to the user as - // it's retrieved from the remote server, using tee; this saves the user from having to wait while - // we process the media on our side - // - // this looks a bit like this: - // - // http fetch pipe - // remote server ------------> data function ----------------> api caller - // | - // | tee - // | - // ▼ - // instance storage - - // This pipe will connect the caller to the in-process media retrieval... - pipeReader, pipeWriter := io.Pipe() - - // Wrap the output pipe to silence any errors during the actual media - // streaming process. We catch the error later but they must be silenced - // during stream to prevent interruptions to storage of the actual media. - silencedWriter := iotools.SilenceWriter(pipeWriter) - - // Pass the reader side of the pipe to the caller to slurp from. - attachmentContent.Content = pipeReader - - // Create a data function which injects the writer end of the pipe - // into the data retrieval process. If something goes wrong while - // doing the data retrieval, we hang up the underlying pipeReader - // to indicate to the caller that no data is available. It's up to - // the caller of this processor function to handle that gracefully. - data = func(innerCtx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(innerCtx, requestingUsername) - if err != nil { - // propagate the transport error to read end of pipe. - _ = pipeWriter.CloseWithError(fmt.Errorf("error getting transport for user: %w", err)) - return nil, 0, err - } - - readCloser, fileSize, err := t.DereferenceMedia(transport.WithFastfail(innerCtx), remoteMediaIRI) - if err != nil { - // propagate the dereference error to read end of pipe. - _ = pipeWriter.CloseWithError(fmt.Errorf("error dereferencing media: %w", err)) - return nil, 0, err - } - - // Make a TeeReader so that everything read from the readCloser, - // aka the remote instance, will also be written into the pipe. - teeReader := io.TeeReader(readCloser, silencedWriter) - - // Wrap teereader to implement original readcloser's close, - // and also ensuring that we close the pipe from write end. - return iotools.ReadFnCloser(teeReader, func() error { - defer func() { - // We use the error (if any) encountered by the - // silenced writer to close connection to make sure it - // gets propagated to the attachment.Content reader. - _ = pipeWriter.CloseWithError(silencedWriter.Error()) - }() - - return readCloser.Close() - }), fileSize, nil - } - } - - // put the media recached in the queue - processingMedia, err := p.mediaManager.RecacheMedia(ctx, data, nil, wantedMediaID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %s", err)) - } - - // if it's the thumbnail, stream the processed thumbnail from storage, after waiting for processing to finish - if mediaSize == media.SizeSmall { - // below function call blocks until all processing on the attachment has finished... - if _, err := processingMedia.LoadAttachment(ctx); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %s", err)) - } - // ... so now we can safely return it - return p.retrieveFromStorage(ctx, storagePath, attachmentContent) - } - - return attachmentContent, nil + // ... so now we can safely return it + return p.retrieveFromStorage(ctx, storagePath, attachmentContent) } func (p *processor) getEmojiContent(ctx context.Context, fileName string, owningAccountID string, emojiSize media.Size) (*apimodel.Content, gtserror.WithCode) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 48971f25c..6541a1fc5 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -26,12 +26,14 @@ "path" "time" + "codeberg.org/gruf/go-bytesize" "codeberg.org/gruf/go-cache/v3/ttl" "codeberg.org/gruf/go-store/v2/kv" "codeberg.org/gruf/go-store/v2/storage" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/log" ) const ( @@ -63,9 +65,14 @@ func (d *Driver) URL(ctx context.Context, key string) *url.URL { return nil } - // access the cache member directly to avoid extending the TTL - if u, ok := d.PresignedCache.Cache.Get(key); ok { - return u.Value + // Check cache underlying cache map directly to + // avoid extending the TTL (which cache.Get() does). + d.PresignedCache.Lock() + e, ok := d.PresignedCache.Cache.Get(key) + d.PresignedCache.Unlock() + + if ok { + return e.Value } u, err := s3.Client().PresignedGetObject(ctx, d.Bucket, key, urlCacheTTL, url.Values{ @@ -88,7 +95,6 @@ func AutoConfig() (*Driver, error) { default: return nil, fmt.Errorf("invalid storage backend: %s", backend) } - } func NewFileStorage() (*Driver, error) { @@ -102,12 +108,17 @@ func NewFileStorage() (*Driver, error) { // overwriting the lockfile if we store a file called 'store.lock'. // However, in this case it's OK because the keys are set by // GtS and not the user, so we know we're never going to overwrite it. - LockFile: path.Join(basePath, "store.lock"), + LockFile: path.Join(basePath, "store.lock"), + WriteBufSize: int(16 * bytesize.KiB), }) if err != nil { return nil, fmt.Errorf("error opening disk storage: %w", err) } + if err := disk.Clean(context.Background()); err != nil { + log.Errorf("error performing storage cleanup: %v", err) + } + return &Driver{ KVStore: kv.New(disk), Storage: disk, diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 9e1acdaa9..3cb2d9f2c 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -51,7 +51,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { // this is necessary because the order of multiple 'context' entries is not determinate trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) + suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) } func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { @@ -72,7 +72,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { // this is necessary because the order of multiple 'context' entries is not determinate trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T12:40:37+02:00"},"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) + suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T12:40:37+02:00"},"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) } func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { @@ -94,7 +94,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { // this is necessary because the order of multiple 'context' entries is not determinate trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) + suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) } func (suite *InternalToASTestSuite) TestOutboxToASCollection() { @@ -157,7 +157,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- // will appear, so trim them out of the string for consistency trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(`{"blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj","mediaType":"image/jpeg","name":"Black and white image of some 50's style text saying: Welcome On Board","type":"Document","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg"},"attributedTo":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","content":"hello world! #welcome ! first post on the instance :rainbow: !","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","replies":{"first":{"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?page=true","next":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"Collection"},"sensitive":false,"summary":"","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T10:40:37Z"},"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"}`, trimmed) + suite.Equal(`{"blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj","mediaType":"image/jpeg","name":"Black and white image of some 50's style text saying: Welcome On Board","type":"Document","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"},"attributedTo":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","content":"hello world! #welcome ! first post on the instance :rainbow: !","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","replies":{"first":{"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?page=true","next":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"Collection"},"sensitive":false,"summary":"","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T10:40:37Z"},"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"}`, trimmed) } func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { @@ -179,7 +179,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- // will appear, so trim them out of the string for consistency trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(`{"blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj","mediaType":"image/jpeg","name":"Black and white image of some 50's style text saying: Welcome On Board","type":"Document","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg"},"attributedTo":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","content":"hello world! #welcome ! first post on the instance :rainbow: !","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","replies":{"first":{"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?page=true","next":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"Collection"},"sensitive":false,"summary":"","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T10:40:37Z"},"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"}`, trimmed) + suite.Equal(`{"blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj","mediaType":"image/jpeg","name":"Black and white image of some 50's style text saying: Welcome On Board","type":"Document","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"},"attributedTo":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","content":"hello world! #welcome ! first post on the instance :rainbow: !","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","replies":{"first":{"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?page=true","next":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/replies","type":"Collection"},"sensitive":false,"summary":"","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji","updated":"2021-09-20T10:40:37Z"},"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"}`, trimmed) } func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index c84950873..8abda5534 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -663,7 +663,7 @@ func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Insta CharactersReservedPerURL: instanceStatusesCharactersReservedPerURL, }, MediaAttachments: &apimodel.InstanceConfigurationMediaAttachments{ - SupportedMimeTypes: media.AllSupportedMIMETypes(), + SupportedMimeTypes: media.SupportedMIMETypes, ImageSizeLimit: int(config.GetMediaImageMaxSize()), // bytes ImageMatrixLimit: instanceMediaAttachmentsImageMatrixLimit, // height*width VideoSizeLimit: int(config.GetMediaVideoMaxSize()), // bytes diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index c13ffca66..494f8becc 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -40,7 +40,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() { @@ -55,7 +55,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { @@ -70,7 +70,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"fields":[],"enable_rss":true,"role":"user"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { @@ -81,7 +81,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { b, err := json.Marshal(apiAccount) suite.NoError(err) - suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"source":{"privacy":"public","language":"en","status_format":"plain","note":"hey yo this is my profile!","fields":[]},"enable_rss":true,"role":"user"}`, string(b)) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"source":{"privacy":"public","language":"en","status_format":"plain","note":"hey yo this is my profile!","fields":[]},"enable_rss":true,"role":"user"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { @@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { b, err := json.Marshal(apiStatus) suite.NoError(err) - suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) + suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() { @@ -107,7 +107,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() b, err := json.Marshal(apiStatus) suite.NoError(err) - suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":null,"uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) + suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":null,"uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { @@ -118,7 +118,7 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { b, err := json.Marshal(apiAttachment) suite.NoError(err) - suite.Equal(`{"id":"01CDR64G398ADCHXK08WWTHEZ5","type":"video","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4","text_url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4","preview_url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":720,"height":404,"frame_rate":"30/1","duration":15.033334,"bitrate":1206522,"size":"720x404","aspect":1.7821782},"small":{"width":720,"height":404,"size":"720x404","aspect":1.7821782},"focus":{"x":0,"y":0}},"description":"A cow adorably licking another cow!"}`, string(b)) + suite.Equal(`{"id":"01CDR64G398ADCHXK08WWTHEZ5","type":"video","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4","text_url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4","preview_url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":720,"height":404,"frame_rate":"30/1","duration":15.033334,"bitrate":1206522,"size":"720x404","aspect":1.7821782},"small":{"width":720,"height":404,"size":"720x404","aspect":1.7821782},"focus":{"x":0,"y":0}},"description":"A cow adorably licking another cow!"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() { diff --git a/internal/typeutils/internaltorss_test.go b/internal/typeutils/internaltorss_test.go index 7baac37ae..b3ced25a5 100644 --- a/internal/typeutils/internaltorss_test.go +++ b/internal/typeutils/internaltorss_test.go @@ -77,7 +77,7 @@ func (suite *InternalToRSSTestSuite) TestStatusToRSSItem2() { suite.EqualValues(1634729805, item.Created.Unix()) suite.Equal("62529", item.Enclosure.Length) suite.Equal("image/jpeg", item.Enclosure.Type) - suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", item.Enclosure.Url) + suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", item.Enclosure.Url) suite.Equal("hello world! #welcome ! first post on the instance \":rainbow:\" !", item.Content) } diff --git a/internal/validate/mediaattachment_test.go b/internal/validate/mediaattachment_test.go index df45ce60d..8bc4259f0 100644 --- a/internal/validate/mediaattachment_test.go +++ b/internal/validate/mediaattachment_test.go @@ -62,7 +62,7 @@ func happyMediaAttachment() *gtsmodel.MediaAttachment { CreatedAt: time.Now().Add(-71 * time.Hour), UpdatedAt: time.Now().Add(-71 * time.Hour), StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", - URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", RemoteURL: "", Type: gtsmodel.FileTypeImage, FileMeta: gtsmodel.FileMeta{ @@ -95,7 +95,7 @@ func happyMediaAttachment() *gtsmodel.MediaAttachment { ContentType: "image/jpeg", FileSize: 6872, UpdatedAt: time.Now().Add(-71 * time.Hour), - URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg", RemoteURL: "", }, Avatar: testrig.FalseBool(), diff --git a/testrig/media/ohyou-original.jpeg b/testrig/media/ohyou-original.jpg similarity index 100% rename from testrig/media/ohyou-original.jpeg rename to testrig/media/ohyou-original.jpg diff --git a/testrig/media/ohyou-small.jpeg b/testrig/media/ohyou-small.jpg similarity index 100% rename from testrig/media/ohyou-small.jpeg rename to testrig/media/ohyou-small.jpg diff --git a/testrig/media/team-fortress-original.jpeg b/testrig/media/team-fortress-original.jpg similarity index 100% rename from testrig/media/team-fortress-original.jpeg rename to testrig/media/team-fortress-original.jpg diff --git a/testrig/media/team-fortress-small.jpeg b/testrig/media/team-fortress-small.jpg similarity index 100% rename from testrig/media/team-fortress-small.jpeg rename to testrig/media/team-fortress-small.jpg diff --git a/testrig/media/thoughtsofdog-original.jpeg b/testrig/media/thoughtsofdog-original.jpg similarity index 100% rename from testrig/media/thoughtsofdog-original.jpeg rename to testrig/media/thoughtsofdog-original.jpg diff --git a/testrig/media/thoughtsofdog-small.jpeg b/testrig/media/thoughtsofdog-small.jpeg deleted file mode 100644 index 35c8f7e98ea9fade74eabef279e89dc1bc5efdd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20395 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 F{Tp+L@=^c* diff --git a/testrig/media/thoughtsofdog-small.jpg b/testrig/media/thoughtsofdog-small.jpg new file mode 100644 index 0000000000000000000000000000000000000000..98801d235b286edd2fc9c21631cfb4b330d72ba8 GIT binary patch literal 19312 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 SUCtC`79x5gv@qZP|4jf8r2_K+ literal 0 HcmV?d00001 diff --git a/testrig/media/trent-small.jpeg b/testrig/media/trent-small.jpg similarity index 100% rename from testrig/media/trent-small.jpeg rename to testrig/media/trent-small.jpg diff --git a/testrig/media/welcome-original.jpeg b/testrig/media/welcome-original.jpg similarity index 100% rename from testrig/media/welcome-original.jpeg rename to testrig/media/welcome-original.jpg diff --git a/testrig/media/welcome-small.jpeg b/testrig/media/welcome-small.jpg similarity index 100% rename from testrig/media/welcome-small.jpeg rename to testrig/media/welcome-small.jpg diff --git a/testrig/media/zork-original.jpeg b/testrig/media/zork-original.jpg similarity index 100% rename from testrig/media/zork-original.jpeg rename to testrig/media/zork-original.jpg diff --git a/testrig/media/zork-small.jpeg b/testrig/media/zork-small.jpg similarity index 100% rename from testrig/media/zork-small.jpeg rename to testrig/media/zork-small.jpg diff --git a/testrig/storage.go b/testrig/storage.go index 2c44260fb..5694b3ab6 100644 --- a/testrig/storage.go +++ b/testrig/storage.go @@ -55,14 +55,14 @@ func StandardStorageSetup(storage *gtsstorage.Driver, relativePath string) { if err != nil { panic(err) } - if err := storage.Put(context.TODO(), pathOriginal, bOriginal); err != nil { + if _, err := storage.Put(context.TODO(), pathOriginal, bOriginal); err != nil { panic(err) } bSmall, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameSmall)) if err != nil { panic(err) } - if err := storage.Put(context.TODO(), pathSmall, bSmall); err != nil { + if _, err := storage.Put(context.TODO(), pathSmall, bSmall); err != nil { panic(err) } } @@ -82,14 +82,14 @@ func StandardStorageSetup(storage *gtsstorage.Driver, relativePath string) { if err != nil { panic(err) } - if err := storage.Put(context.TODO(), pathOriginal, bOriginal); err != nil { + if _, err := storage.Put(context.TODO(), pathOriginal, bOriginal); err != nil { panic(err) } bStatic, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameStatic)) if err != nil { panic(err) } - if err := storage.Put(context.TODO(), pathStatic, bStatic); err != nil { + if _, err := storage.Put(context.TODO(), pathStatic, bStatic); err != nil { panic(err) } } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 88c5df77a..035744f93 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -689,7 +689,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "admin_account_status_1_attachment_1": { ID: "01F8MH6NEM8D7527KZAECTCR76", StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", - URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", RemoteURL: "", CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), @@ -714,17 +714,17 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LNJRdVM{00Rj%Mayt7j[4nWBofRj", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", + Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", ContentType: "image/jpeg", FileSize: 62529, UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg", + Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg", ContentType: "image/jpeg", FileSize: 6872, UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg", RemoteURL: "", }, Avatar: FalseBool(), @@ -769,11 +769,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpg", ContentType: "image/jpeg", FileSize: 8803, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpg", RemoteURL: "", }, Avatar: FalseBool(), @@ -821,11 +821,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpg", ContentType: "image/jpeg", FileSize: 5272, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpg", RemoteURL: "", }, Avatar: FalseBool(), @@ -835,7 +835,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "local_account_1_unattached_1": { ID: "01F8MH8RMYQ6MSNY3JM2XT1CQ5", StatusID: "", // this attachment isn't connected to a status YET - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", RemoteURL: "", CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), @@ -864,17 +864,17 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LSAd]9ogDge-R:M|j=xWIto0xXWX", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", ContentType: "image/jpeg", FileSize: 27759, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", ContentType: "image/jpeg", FileSize: 6177, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", RemoteURL: "", }, Avatar: FalseBool(), @@ -884,7 +884,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "local_account_1_avatar": { ID: "01F8MH58A357CV5K7R7TJMSH6S", StatusID: "", // this attachment isn't connected to a status - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", RemoteURL: "", CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), @@ -913,17 +913,17 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LKK9MT,p|YSNDkJ-5rsmvnwcOoe:", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", ContentType: "image/jpeg", FileSize: 457680, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", ContentType: "image/jpeg", FileSize: 15374, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", RemoteURL: "", }, Avatar: TrueBool(), @@ -933,7 +933,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "local_account_1_header": { ID: "01PFPMWK2FF0D9WMHEJHR07C3Q", StatusID: "", - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", RemoteURL: "", CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), @@ -962,17 +962,17 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "L26j{^WCs+R-N}jsxWj@4;WWxDoK", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", ContentType: "image/jpeg", FileSize: 517226, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", + Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", ContentType: "image/jpeg", FileSize: 42308, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", RemoteURL: "", }, Avatar: FalseBool(), @@ -982,8 +982,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "remote_account_1_status_1_attachment_1": { ID: "01FVW7RXPQ8YJHTEXYPE7Q8ZY0", StatusID: "01FVW7JHQFSFK166WWKR8CBA6M", - URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg", CreatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), Type: gtsmodel.FileTypeImage, @@ -1011,18 +1011,18 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LARysgM_IU_3~pD%M_Rj_39FIAt6", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", + Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", FileSize: 19310, UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", + Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", - FileSize: 20395, + FileSize: 19312, UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), - URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", }, Avatar: FalseBool(), Header: FalseBool(), @@ -1031,8 +1031,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "remote_account_1_status_1_attachment_2": { ID: "01FVW7RXPQ8YJHTEXYPE7Q8ZY1", StatusID: "01FVW7JHQFSFK166WWKR8CBA6M", - URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg", CreatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), Type: gtsmodel.FileTypeImage, @@ -1060,18 +1060,18 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LARysgM_IU_3~pD%M_Rj_39FIAt6", Processing: 2, File: gtsmodel.File{ - Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", + Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", FileSize: 19310, UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", + Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", FileSize: 20395, UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), - URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg", + URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", }, Avatar: FalseBool(), Header: FalseBool(), @@ -1080,8 +1080,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { "remote_account_3_header": { ID: "01PFPMWK2FF0D9WMHEJHR07C3R", StatusID: "", - URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg", + URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), Type: gtsmodel.FileTypeImage, @@ -1109,18 +1109,18 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Blurhash: "LARysgM_IU_3~pD%M_Rj_39FIAt6", Processing: 2, File: gtsmodel.File{ - Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", + Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", ContentType: "image/jpeg", FileSize: 19310, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ - Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", + Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", ContentType: "image/jpeg", FileSize: 20395, UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), - URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", - RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpeg", + URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", }, Avatar: FalseBool(), Header: TrueBool(), @@ -1262,32 +1262,32 @@ type filenames struct { func newTestStoredAttachments() map[string]filenames { return map[string]filenames{ "admin_account_status_1_attachment_1": { - Original: "welcome-original.jpeg", - Small: "welcome-small.jpeg", + Original: "welcome-original.jpg", + Small: "welcome-small.jpg", }, "local_account_1_status_4_attachment_1": { Original: "trent-original.gif", - Small: "trent-small.jpeg", + Small: "trent-small.jpg", }, "local_account_1_status_4_attachment_2": { Original: "cowlick-original.mp4", Small: "cowlick-small.jpeg", }, "local_account_1_unattached_1": { - Original: "ohyou-original.jpeg", - Small: "ohyou-small.jpeg", + Original: "ohyou-original.jpg", + Small: "ohyou-small.jpg", }, "local_account_1_avatar": { - Original: "zork-original.jpeg", - Small: "zork-small.jpeg", + Original: "zork-original.jpg", + Small: "zork-small.jpg", }, "local_account_1_header": { - Original: "team-fortress-original.jpeg", - Small: "team-fortress-small.jpeg", + Original: "team-fortress-original.jpg", + Small: "team-fortress-small.jpg", }, "remote_account_1_status_1_attachment_1": { - Original: "thoughtsofdog-original.jpeg", - Small: "thoughtsofdog-small.jpeg", + Original: "thoughtsofdog-original.jpg", + Small: "thoughtsofdog-small.jpg", }, } } @@ -2070,7 +2070,7 @@ func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]Activit []vocab.ActivityStreamsMention{}, []vocab.ActivityStreamsImage{ newAPImage( - URLMustParse("http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1/attachment1.jpeg"), + URLMustParse("http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1/attachment1.jpg"), "image/jpeg", "trent reznor looking handsome as balls", "LEDara58O=t5EMSOENEN9]}?aK%0"), @@ -2322,7 +2322,7 @@ func NewTestFediAttachments(relativePath string) map[string]RemoteAttachmentFile panic(err) } - thoughtsOfDogBytes, err := os.ReadFile(fmt.Sprintf("%s/thoughtsofdog-original.jpeg", relativePath)) + thoughtsOfDogBytes, err := os.ReadFile(fmt.Sprintf("%s/thoughtsofdog-original.jpg", relativePath)) if err != nil { panic(err) } @@ -2352,7 +2352,7 @@ func NewTestFediAttachments(relativePath string) map[string]RemoteAttachmentFile Data: beeBytes, ContentType: "image/jpeg", }, - "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpeg": { + "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg": { Data: thoughtsOfDogBytes, ContentType: "image/jpeg", }, @@ -2390,7 +2390,7 @@ func NewTestFediStatuses() map[string]vocab.ActivityStreamsNote { []vocab.ActivityStreamsMention{}, []vocab.ActivityStreamsImage{ newAPImage( - URLMustParse("http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1/attachment1.jpeg"), + URLMustParse("http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1/attachment1.jpg"), "image/jpeg", "trent reznor looking handsome as balls", "LEDara58O=t5EMSOENEN9]}?aK%0"), diff --git a/vendor/codeberg.org/gruf/go-fastcopy/copy.go b/vendor/codeberg.org/gruf/go-fastcopy/copy.go index 4716b140f..a9c115927 100644 --- a/vendor/codeberg.org/gruf/go-fastcopy/copy.go +++ b/vendor/codeberg.org/gruf/go-fastcopy/copy.go @@ -78,16 +78,16 @@ func (cp *CopyPool) Copy(dst io.Writer, src io.Reader) (int64, error) { var buf []byte - if b, ok := cp.pool.Get().([]byte); ok { + if b, ok := cp.pool.Get().(*[]byte); ok { // Acquired buf from pool - buf = b + buf = *b } else { // Allocate new buffer of size buf = make([]byte, cp.Buffer(0)) } // Defer release to pool - defer cp.pool.Put(buf) + defer cp.pool.Put(&buf) var n int64 for { diff --git a/vendor/codeberg.org/gruf/go-iotools/LICENSE b/vendor/codeberg.org/gruf/go-iotools/LICENSE new file mode 100644 index 000000000..e4163ae35 --- /dev/null +++ b/vendor/codeberg.org/gruf/go-iotools/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2022 gruf + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/codeberg.org/gruf/go-iotools/close.go b/vendor/codeberg.org/gruf/go-iotools/close.go new file mode 100644 index 000000000..fbed7f33c --- /dev/null +++ b/vendor/codeberg.org/gruf/go-iotools/close.go @@ -0,0 +1,35 @@ +package iotools + +import "io" + +// CloserFunc is a function signature which allows +// a function to implement the io.Closer type. +type CloserFunc func() error + +func (c CloserFunc) Close() error { + return c() +} + +func CloserCallback(c io.Closer, cb func()) io.Closer { + return CloserFunc(func() error { + defer cb() + return c.Close() + }) +} + +// CloseOnce wraps an io.Closer to ensure it only performs the close logic once. +func CloseOnce(c io.Closer) io.Closer { + return CloserFunc(func() error { + if c == nil { + // already run. + return nil + } + + // Acquire. + cptr := c + c = nil + + // Call the closer. + return cptr.Close() + }) +} diff --git a/vendor/codeberg.org/gruf/go-iotools/read.go b/vendor/codeberg.org/gruf/go-iotools/read.go new file mode 100644 index 000000000..4a134e7b3 --- /dev/null +++ b/vendor/codeberg.org/gruf/go-iotools/read.go @@ -0,0 +1,28 @@ +package iotools + +import ( + "io" +) + +// ReaderFunc is a function signature which allows +// a function to implement the io.Reader type. +type ReaderFunc func([]byte) (int, error) + +func (r ReaderFunc) Read(b []byte) (int, error) { + return r(b) +} + +// ReadCloser wraps an io.Reader and io.Closer in order to implement io.ReadCloser. +func ReadCloser(r io.Reader, c io.Closer) io.ReadCloser { + return &struct { + io.Reader + io.Closer + }{r, c} +} + +// NopReadCloser wraps an io.Reader to implement io.ReadCloser with empty io.Closer implementation. +func NopReadCloser(r io.Reader) io.ReadCloser { + return ReadCloser(r, CloserFunc(func() error { + return nil + })) +} diff --git a/vendor/codeberg.org/gruf/go-iotools/write.go b/vendor/codeberg.org/gruf/go-iotools/write.go new file mode 100644 index 000000000..c520b8636 --- /dev/null +++ b/vendor/codeberg.org/gruf/go-iotools/write.go @@ -0,0 +1,26 @@ +package iotools + +import "io" + +// WriterFunc is a function signature which allows +// a function to implement the io.Writer type. +type WriterFunc func([]byte) (int, error) + +func (w WriterFunc) Write(b []byte) (int, error) { + return w(b) +} + +// WriteCloser wraps an io.Writer and io.Closer in order to implement io.WriteCloser. +func WriteCloser(w io.Writer, c io.Closer) io.WriteCloser { + return &struct { + io.Writer + io.Closer + }{w, c} +} + +// NopWriteCloser wraps an io.Writer to implement io.WriteCloser with empty io.Closer implementation. +func NopWriteCloser(w io.Writer) io.WriteCloser { + return WriteCloser(w, CloserFunc(func() error { + return nil + })) +} diff --git a/vendor/codeberg.org/gruf/go-mutexes/map.go b/vendor/codeberg.org/gruf/go-mutexes/map.go index a3c171c7a..73f8f1821 100644 --- a/vendor/codeberg.org/gruf/go-mutexes/map.go +++ b/vendor/codeberg.org/gruf/go-mutexes/map.go @@ -454,7 +454,9 @@ func (mu *rwmutex) Unlock() { if mu.rcnt > 0 { // RUnlock mu.rcnt-- - } else { + } + + if mu.rcnt == 0 { // Total unlock mu.lock = 0 } diff --git a/vendor/codeberg.org/gruf/go-store/v2/kv/state.go b/vendor/codeberg.org/gruf/go-store/v2/kv/state.go index 9ac8ab1bf..450cd850c 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/kv/state.go +++ b/vendor/codeberg.org/gruf/go-store/v2/kv/state.go @@ -77,17 +77,17 @@ func (st *StateRW) GetStream(ctx context.Context, key string) (io.ReadCloser, er } // Put: see KVStore.Put(). Returns error if state already closed. -func (st *StateRW) Put(ctx context.Context, key string, value []byte) error { +func (st *StateRW) Put(ctx context.Context, key string, value []byte) (int, error) { if st.store == nil { - return ErrStateClosed + return 0, ErrStateClosed } return st.store.put(st.state.Lock, ctx, key, value) } // PutStream: see KVStore.PutStream(). Returns error if state already closed. -func (st *StateRW) PutStream(ctx context.Context, key string, r io.Reader) error { +func (st *StateRW) PutStream(ctx context.Context, key string, r io.Reader) (int64, error) { if st.store == nil { - return ErrStateClosed + return 0, ErrStateClosed } return st.store.putStream(st.state.Lock, ctx, key, r) } diff --git a/vendor/codeberg.org/gruf/go-store/v2/kv/store.go b/vendor/codeberg.org/gruf/go-store/v2/kv/store.go index 5ea795e7c..0b878c47f 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/kv/store.go +++ b/vendor/codeberg.org/gruf/go-store/v2/kv/store.go @@ -4,9 +4,9 @@ "context" "io" + "codeberg.org/gruf/go-iotools" "codeberg.org/gruf/go-mutexes" "codeberg.org/gruf/go-store/v2/storage" - "codeberg.org/gruf/go-store/v2/util" ) // KVStore is a very simple, yet performant key-value store @@ -117,17 +117,25 @@ func (st *KVStore) getStream(rlock func(string) func(), ctx context.Context, key return nil, err } - // Wrap readcloser in our own callback closer - return util.ReadCloserWithCallback(rd, runlock), nil + var unlocked bool + + // Wrap readcloser to call our own callback + return iotools.ReadCloser(rd, iotools.CloserFunc(func() error { + if !unlocked { + unlocked = true + defer runlock() + } + return rd.Close() + })), nil } // Put places the bytes at the supplied key in the store. -func (st *KVStore) Put(ctx context.Context, key string, value []byte) error { +func (st *KVStore) Put(ctx context.Context, key string, value []byte) (int, error) { return st.put(st.Lock, ctx, key, value) } // put performs the underlying logic for KVStore.Put(), using supplied lock func to allow use with states. -func (st *KVStore) put(lock func(string) func(), ctx context.Context, key string, value []byte) error { +func (st *KVStore) put(lock func(string) func(), ctx context.Context, key string, value []byte) (int, error) { // Acquire write lock for key unlock := lock(key) defer unlock() @@ -137,12 +145,12 @@ func (st *KVStore) put(lock func(string) func(), ctx context.Context, key string } // PutStream writes the bytes from the supplied Reader at the supplied key in the store. -func (st *KVStore) PutStream(ctx context.Context, key string, r io.Reader) error { +func (st *KVStore) PutStream(ctx context.Context, key string, r io.Reader) (int64, error) { return st.putStream(st.Lock, ctx, key, r) } // putStream performs the underlying logic for KVStore.PutStream(), using supplied lock func to allow use with states. -func (st *KVStore) putStream(lock func(string) func(), ctx context.Context, key string, r io.Reader) error { +func (st *KVStore) putStream(lock func(string) func(), ctx context.Context, key string, r io.Reader) (int64, error) { // Acquire write lock for key unlock := lock(key) defer unlock() diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/block.go b/vendor/codeberg.org/gruf/go-store/v2/storage/block.go index f41099c75..11a757211 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/block.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/block.go @@ -10,12 +10,14 @@ "os" "strings" "sync" + "sync/atomic" "syscall" "codeberg.org/gruf/go-byteutil" "codeberg.org/gruf/go-errors/v2" "codeberg.org/gruf/go-fastcopy" "codeberg.org/gruf/go-hashenc" + "codeberg.org/gruf/go-iotools" "codeberg.org/gruf/go-pools" "codeberg.org/gruf/go-store/v2/util" ) @@ -354,7 +356,7 @@ func (st *BlockStorage) ReadStream(ctx context.Context, key string) (io.ReadClos } // Prepare block reader and return - return util.NopReadCloser(&blockReader{ + return iotools.NopReadCloser(&blockReader{ storage: st, node: &node, }), nil @@ -384,52 +386,54 @@ func (st *BlockStorage) readBlock(key string) ([]byte, error) { } // WriteBytes implements Storage.WriteBytes(). -func (st *BlockStorage) WriteBytes(ctx context.Context, key string, value []byte) error { - return st.WriteStream(ctx, key, bytes.NewReader(value)) +func (st *BlockStorage) WriteBytes(ctx context.Context, key string, value []byte) (int, error) { + n, err := st.WriteStream(ctx, key, bytes.NewReader(value)) + return int(n), err } // WriteStream implements Storage.WriteStream(). -func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader) error { +func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader) (int64, error) { // Get node file path for key npath, err := st.nodePathForKey(key) if err != nil { - return err + return 0, err } // Check if open if st.lock.Closed() { - return ErrClosed + return 0, ErrClosed } // Check context still valid if err := ctx.Err(); err != nil { - return err + return 0, err } // Check if this exists ok, err := stat(key) if err != nil { - return err + return 0, err } // Check if we allow overwrites if ok && !st.config.Overwrite { - return ErrAlreadyExists + return 0, ErrAlreadyExists } // Ensure nodes dir (and any leading up to) exists err = os.MkdirAll(st.nodePath, defaultDirPerms) if err != nil { - return err + return 0, err } // Ensure blocks dir (and any leading up to) exists err = os.MkdirAll(st.blockPath, defaultDirPerms) if err != nil { - return err + return 0, err } var node node + var total atomic.Int64 // Acquire HashEncoder hc := st.hashPool.Get().(*hashEncoder) @@ -456,7 +460,7 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader break loop default: st.bufpool.Put(buf) - return err + return 0, err } // Hash the encoded data @@ -469,7 +473,7 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader has, err := st.statBlock(sum) if err != nil { st.bufpool.Put(buf) - return err + return 0, err } else if has { st.bufpool.Put(buf) continue loop @@ -490,11 +494,14 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader }() // Write block to store at hash - err = st.writeBlock(sum, buf.B[:n]) + n, err := st.writeBlock(sum, buf.B[:n]) if err != nil { onceErr.Store(err) return } + + // Increment total. + total.Add(int64(n)) }() // Break at end @@ -506,12 +513,12 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader // Wait, check errors wg.Wait() if onceErr.IsSet() { - return onceErr.Load() + return 0, onceErr.Load() } // If no hashes created, return if len(node.hashes) < 1 { - return new_error("no hashes written") + return 0, new_error("no hashes written") } // Prepare to swap error if need-be @@ -535,7 +542,7 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader // Attempt to open RW file file, err := open(npath, flags) if err != nil { - return errSwap(err) + return 0, errSwap(err) } defer file.Close() @@ -546,11 +553,11 @@ func (st *BlockStorage) WriteStream(ctx context.Context, key string, r io.Reader // Finally, write data to file _, err = io.CopyBuffer(file, &nodeReader{node: node}, buf.B) - return err + return total.Load(), err } // writeBlock writes the block with hash and supplied value to the filesystem. -func (st *BlockStorage) writeBlock(hash string, value []byte) error { +func (st *BlockStorage) writeBlock(hash string, value []byte) (int, error) { // Get block file path for key bpath := st.blockPathForKey(hash) @@ -560,20 +567,19 @@ func (st *BlockStorage) writeBlock(hash string, value []byte) error { if err == syscall.EEXIST { err = nil /* race issue describe in struct NOTE */ } - return err + return 0, err } defer file.Close() // Wrap the file in a compressor cFile, err := st.config.Compression.Writer(file) if err != nil { - return err + return 0, err } defer cFile.Close() // Write value to file - _, err = cFile.Write(value) - return err + return cFile.Write(value) } // statBlock checks for existence of supplied block hash. diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/compressor.go b/vendor/codeberg.org/gruf/go-store/v2/storage/compressor.go index 6eeb3a78d..bbe02f22d 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/compressor.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/compressor.go @@ -5,7 +5,7 @@ "io" "sync" - "codeberg.org/gruf/go-store/v2/util" + "codeberg.org/gruf/go-iotools" "github.com/klauspost/compress/gzip" "github.com/klauspost/compress/snappy" @@ -15,10 +15,10 @@ // Compressor defines a means of compressing/decompressing values going into a key-value store type Compressor interface { // Reader returns a new decompressing io.ReadCloser based on supplied (compressed) io.Reader - Reader(io.Reader) (io.ReadCloser, error) + Reader(io.ReadCloser) (io.ReadCloser, error) // Writer returns a new compressing io.WriteCloser based on supplied (uncompressed) io.Writer - Writer(io.Writer) (io.WriteCloser, error) + Writer(io.WriteCloser) (io.WriteCloser, error) } type gzipCompressor struct { @@ -47,8 +47,8 @@ func GZipCompressorLevel(level int) Compressor { // Write empty data to ensure gzip // header data is in byte buffer. - gw.Write([]byte{}) - gw.Close() + _, _ = gw.Write([]byte{}) + _ = gw.Close() return &gzipCompressor{ rpool: sync.Pool{ @@ -67,23 +67,61 @@ func GZipCompressorLevel(level int) Compressor { } } -func (c *gzipCompressor) Reader(r io.Reader) (io.ReadCloser, error) { +func (c *gzipCompressor) Reader(rc io.ReadCloser) (io.ReadCloser, error) { + var released bool + + // Acquire from pool. gr := c.rpool.Get().(*gzip.Reader) - if err := gr.Reset(r); err != nil { + if err := gr.Reset(rc); err != nil { c.rpool.Put(gr) return nil, err } - return util.ReadCloserWithCallback(gr, func() { - c.rpool.Put(gr) - }), nil + + return iotools.ReadCloser(gr, iotools.CloserFunc(func() error { + if !released { + released = true + defer c.rpool.Put(gr) + } + + // Close compressor + err1 := gr.Close() + + // Close original stream. + err2 := rc.Close() + + // Return err1 or 2 + if err1 != nil { + return err1 + } + return err2 + })), nil } -func (c *gzipCompressor) Writer(w io.Writer) (io.WriteCloser, error) { +func (c *gzipCompressor) Writer(wc io.WriteCloser) (io.WriteCloser, error) { + var released bool + + // Acquire from pool. gw := c.wpool.Get().(*gzip.Writer) - gw.Reset(w) - return util.WriteCloserWithCallback(gw, func() { - c.wpool.Put(gw) - }), nil + gw.Reset(wc) + + return iotools.WriteCloser(gw, iotools.CloserFunc(func() error { + if !released { + released = true + c.wpool.Put(gw) + } + + // Close compressor + err1 := gw.Close() + + // Close original stream. + err2 := wc.Close() + + // Return err1 or 2 + if err1 != nil { + return err1 + } + return err2 + })), nil } type zlibCompressor struct { @@ -139,26 +177,61 @@ func ZLibCompressorLevelDict(level int, dict []byte) Compressor { } } -func (c *zlibCompressor) Reader(r io.Reader) (io.ReadCloser, error) { +func (c *zlibCompressor) Reader(rc io.ReadCloser) (io.ReadCloser, error) { + var released bool zr := c.rpool.Get().(interface { io.ReadCloser zlib.Resetter }) - if err := zr.Reset(r, c.dict); err != nil { + if err := zr.Reset(rc, c.dict); err != nil { c.rpool.Put(zr) return nil, err } - return util.ReadCloserWithCallback(zr, func() { - c.rpool.Put(zr) - }), nil + return iotools.ReadCloser(zr, iotools.CloserFunc(func() error { + if !released { + released = true + defer c.rpool.Put(zr) + } + + // Close compressor + err1 := zr.Close() + + // Close original stream. + err2 := rc.Close() + + // Return err1 or 2 + if err1 != nil { + return err1 + } + return err2 + })), nil } -func (c *zlibCompressor) Writer(w io.Writer) (io.WriteCloser, error) { +func (c *zlibCompressor) Writer(wc io.WriteCloser) (io.WriteCloser, error) { + var released bool + + // Acquire from pool. zw := c.wpool.Get().(*zlib.Writer) - zw.Reset(w) - return util.WriteCloserWithCallback(zw, func() { - c.wpool.Put(zw) - }), nil + zw.Reset(wc) + + return iotools.WriteCloser(zw, iotools.CloserFunc(func() error { + if !released { + released = true + c.wpool.Put(zw) + } + + // Close compressor + err1 := zw.Close() + + // Close original stream. + err2 := wc.Close() + + // Return err1 or 2 + if err1 != nil { + return err1 + } + return err2 + })), nil } type snappyCompressor struct { @@ -178,22 +251,40 @@ func SnappyCompressor() Compressor { } } -func (c *snappyCompressor) Reader(r io.Reader) (io.ReadCloser, error) { +func (c *snappyCompressor) Reader(rc io.ReadCloser) (io.ReadCloser, error) { + var released bool + + // Acquire from pool. sr := c.rpool.Get().(*snappy.Reader) - sr.Reset(r) - return util.ReadCloserWithCallback( - util.NopReadCloser(sr), - func() { c.rpool.Put(sr) }, - ), nil + sr.Reset(rc) + + return iotools.ReadCloser(sr, iotools.CloserFunc(func() error { + if !released { + released = true + defer c.rpool.Put(sr) + } + + // Close original stream. + return rc.Close() + })), nil } -func (c *snappyCompressor) Writer(w io.Writer) (io.WriteCloser, error) { +func (c *snappyCompressor) Writer(wc io.WriteCloser) (io.WriteCloser, error) { + var released bool + + // Acquire from pool. sw := c.wpool.Get().(*snappy.Writer) - sw.Reset(w) - return util.WriteCloserWithCallback( - util.NopWriteCloser(sw), - func() { c.wpool.Put(sw) }, - ), nil + sw.Reset(wc) + + return iotools.WriteCloser(sw, iotools.CloserFunc(func() error { + if !released { + released = true + c.wpool.Put(sw) + } + + // Close original stream. + return wc.Close() + })), nil } type nopCompressor struct{} @@ -203,10 +294,10 @@ func NoCompression() Compressor { return &nopCompressor{} } -func (c *nopCompressor) Reader(r io.Reader) (io.ReadCloser, error) { - return util.NopReadCloser(r), nil +func (c *nopCompressor) Reader(rc io.ReadCloser) (io.ReadCloser, error) { + return rc, nil } -func (c *nopCompressor) Writer(w io.Writer) (io.WriteCloser, error) { - return util.NopWriteCloser(w), nil +func (c *nopCompressor) Writer(wc io.WriteCloser) (io.WriteCloser, error) { + return wc, nil } diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/disk.go b/vendor/codeberg.org/gruf/go-store/v2/storage/disk.go index ef6993edd..21dba7671 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/disk.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/disk.go @@ -219,43 +219,41 @@ func (st *DiskStorage) ReadStream(ctx context.Context, key string) (io.ReadClose // Wrap the file in a compressor cFile, err := st.config.Compression.Reader(file) if err != nil { - file.Close() // close this here, ignore error + _ = file.Close() return nil, err } - // Wrap compressor to ensure file close - return util.ReadCloserWithCallback(cFile, func() { - file.Close() - }), nil + return cFile, nil } // WriteBytes implements Storage.WriteBytes(). -func (st *DiskStorage) WriteBytes(ctx context.Context, key string, value []byte) error { - return st.WriteStream(ctx, key, bytes.NewReader(value)) +func (st *DiskStorage) WriteBytes(ctx context.Context, key string, value []byte) (int, error) { + n, err := st.WriteStream(ctx, key, bytes.NewReader(value)) + return int(n), err } // WriteStream implements Storage.WriteStream(). -func (st *DiskStorage) WriteStream(ctx context.Context, key string, r io.Reader) error { +func (st *DiskStorage) WriteStream(ctx context.Context, key string, r io.Reader) (int64, error) { // Get file path for key kpath, err := st.filepath(key) if err != nil { - return err + return 0, err } // Check if open if st.lock.Closed() { - return ErrClosed + return 0, ErrClosed } // Check context still valid if err := ctx.Err(); err != nil { - return err + return 0, err } // Ensure dirs leading up to file exist err = os.MkdirAll(path.Dir(kpath), defaultDirPerms) if err != nil { - return err + return 0, err } // Prepare to swap error if need-be @@ -273,20 +271,21 @@ func (st *DiskStorage) WriteStream(ctx context.Context, key string, r io.Reader) // Attempt to open file file, err := open(kpath, flags) if err != nil { - return errSwap(err) + return 0, errSwap(err) } - defer file.Close() // Wrap the file in a compressor cFile, err := st.config.Compression.Writer(file) if err != nil { - return err + _ = file.Close() + return 0, err } + + // Wraps file.Close(). defer cFile.Close() // Copy provided reader to file - _, err = st.cppool.Copy(cFile, r) - return err + return st.cppool.Copy(cFile, r) } // Stat implements Storage.Stat(). diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/fs.go b/vendor/codeberg.org/gruf/go-store/v2/storage/fs.go index 48a5806f2..be86ac127 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/fs.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/fs.go @@ -1,6 +1,7 @@ package storage import ( + "fmt" "io/fs" "os" "syscall" @@ -102,46 +103,32 @@ type frame struct { // cleanDirs traverses the dir tree of the supplied path, removing any folders with zero children func cleanDirs(path string) error { - // Acquire path builder pb := util.GetPathBuilder() defer util.PutPathBuilder(pb) - - // Get top-level dir entries - entries, err := readDir(path) - if err != nil { - return err - } - - for _, entry := range entries { - if entry.IsDir() { - // Recursively clean sub-directory entries - if err := cleanDir(pb, pb.Join(path, entry.Name())); err != nil { - return err - } - } - } - - return nil + return cleanDir(pb, path, true) } // cleanDir performs the actual dir cleaning logic for the above top-level version. -func cleanDir(pb *fastpath.Builder, path string) error { - // Get dir entries +func cleanDir(pb *fastpath.Builder, path string, top bool) error { + // Get dir entries at path. entries, err := readDir(path) if err != nil { return err } - // If no entries, delete - if len(entries) < 1 { + // If no entries, delete dir. + if !top && len(entries) == 0 { return rmdir(path) } for _, entry := range entries { if entry.IsDir() { - // Recursively clean sub-directory entries - if err := cleanDir(pb, pb.Join(path, entry.Name())); err != nil { - return err + // Calculate directory path. + dirPath := pb.Join(path, entry.Name()) + + // Recursively clean sub-directory entries. + if err := cleanDir(pb, dirPath, false); err != nil { + fmt.Fprintf(os.Stderr, "[go-store/storage] error cleaning %s: %v", dirPath, err) } } } diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/memory.go b/vendor/codeberg.org/gruf/go-store/v2/storage/memory.go index a853c84d2..d42274e39 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/memory.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/memory.go @@ -6,7 +6,7 @@ "sync/atomic" "codeberg.org/gruf/go-bytes" - "codeberg.org/gruf/go-store/v2/util" + "codeberg.org/gruf/go-iotools" "github.com/cornelk/hashmap" ) @@ -86,57 +86,57 @@ func (st *MemoryStorage) ReadStream(ctx context.Context, key string) (io.ReadClo // Create io.ReadCloser from 'b' copy r := bytes.NewReader(copyb(b)) - return util.NopReadCloser(r), nil + return iotools.NopReadCloser(r), nil } // WriteBytes implements Storage.WriteBytes(). -func (st *MemoryStorage) WriteBytes(ctx context.Context, key string, b []byte) error { +func (st *MemoryStorage) WriteBytes(ctx context.Context, key string, b []byte) (int, error) { // Check store open if st.closed() { - return ErrClosed + return 0, ErrClosed } // Check context still valid if err := ctx.Err(); err != nil { - return err + return 0, err } // Check for key that already exists if _, ok := st.fs.Get(key); ok && !st.ow { - return ErrAlreadyExists + return 0, ErrAlreadyExists } // Write key copy to store st.fs.Set(key, copyb(b)) - return nil + return len(b), nil } // WriteStream implements Storage.WriteStream(). -func (st *MemoryStorage) WriteStream(ctx context.Context, key string, r io.Reader) error { +func (st *MemoryStorage) WriteStream(ctx context.Context, key string, r io.Reader) (int64, error) { // Check store open if st.closed() { - return ErrClosed + return 0, ErrClosed } // Check context still valid if err := ctx.Err(); err != nil { - return err + return 0, err } // Check for key that already exists if _, ok := st.fs.Get(key); ok && !st.ow { - return ErrAlreadyExists + return 0, ErrAlreadyExists } // Read all from reader b, err := io.ReadAll(r) if err != nil { - return err + return 0, err } // Write key to store st.fs.Set(key, b) - return nil + return int64(len(b)), nil } // Stat implements Storage.Stat(). diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/s3.go b/vendor/codeberg.org/gruf/go-store/v2/storage/s3.go index f8011114f..501de230d 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/s3.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/s3.go @@ -160,22 +160,23 @@ func (st *S3Storage) ReadStream(ctx context.Context, key string) (io.ReadCloser, } // WriteBytes implements Storage.WriteBytes(). -func (st *S3Storage) WriteBytes(ctx context.Context, key string, value []byte) error { - return st.WriteStream(ctx, key, util.NewByteReaderSize(value)) +func (st *S3Storage) WriteBytes(ctx context.Context, key string, value []byte) (int, error) { + n, err := st.WriteStream(ctx, key, util.NewByteReaderSize(value)) + return int(n), err } // WriteStream implements Storage.WriteStream(). -func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) error { +func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) (int64, error) { // Check storage open if st.closed() { - return ErrClosed + return 0, ErrClosed } if rs, ok := r.(util.ReaderSize); ok { // This reader supports providing us the size of // the encompassed data, allowing us to perform // a singular .PutObject() call with length. - _, err := st.client.PutObject( + info, err := st.client.PutObject( ctx, st.bucket, key, @@ -186,9 +187,9 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e st.config.PutOpts, ) if err != nil { - return transformS3Error(err) + err = transformS3Error(err) } - return nil + return info.Size, err } // Start a new multipart upload to get ID @@ -199,14 +200,15 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e st.config.PutOpts, ) if err != nil { - return transformS3Error(err) + return 0, transformS3Error(err) } var ( - count = 1 + index = int(1) // parts index + total = int64(0) parts []minio.CompletePart chunk = make([]byte, st.config.PutChunkSize) - rdr = bytes.NewReader(nil) + rbuf = bytes.NewReader(nil) ) // Note that we do not perform any kind of @@ -234,11 +236,11 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e // All other errors default: - return err + return 0, err } // Reset byte reader - rdr.Reset(chunk[:n]) + rbuf.Reset(chunk[:n]) // Put this object chunk in S3 store pt, err := st.client.PutObjectPart( @@ -246,15 +248,15 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e st.bucket, key, uploadID, - count, - rdr, + index, + rbuf, int64(n), "", "", nil, ) if err != nil { - return err + return 0, err } // Append completed part to slice @@ -267,8 +269,11 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e ChecksumSHA256: pt.ChecksumSHA256, }) - // Iterate part count - count++ + // Iterate idx + index++ + + // Update total size + total += pt.Size } // Complete this multi-part upload operation @@ -281,10 +286,10 @@ func (st *S3Storage) WriteStream(ctx context.Context, key string, r io.Reader) e st.config.PutOpts, ) if err != nil { - return err + return 0, err } - return nil + return total, nil } // Stat implements Storage.Stat(). diff --git a/vendor/codeberg.org/gruf/go-store/v2/storage/storage.go b/vendor/codeberg.org/gruf/go-store/v2/storage/storage.go index 00fbe7abd..a60ea93ad 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/storage/storage.go +++ b/vendor/codeberg.org/gruf/go-store/v2/storage/storage.go @@ -14,10 +14,10 @@ type Storage interface { ReadStream(ctx context.Context, key string) (io.ReadCloser, error) // WriteBytes writes the supplied value bytes at key in the storage - WriteBytes(ctx context.Context, key string, value []byte) error + WriteBytes(ctx context.Context, key string, value []byte) (int, error) // WriteStream writes the bytes from supplied reader at key in the storage - WriteStream(ctx context.Context, key string, r io.Reader) error + WriteStream(ctx context.Context, key string, r io.Reader) (int64, error) // Stat checks if the supplied key is in the storage Stat(ctx context.Context, key string) (bool, error) diff --git a/vendor/codeberg.org/gruf/go-store/v2/util/io.go b/vendor/codeberg.org/gruf/go-store/v2/util/io.go index 3d62e8be6..c5135084a 100644 --- a/vendor/codeberg.org/gruf/go-store/v2/util/io.go +++ b/vendor/codeberg.org/gruf/go-store/v2/util/io.go @@ -5,102 +5,37 @@ "io" ) -// ReaderSize ... +// ReaderSize defines a reader of known size in bytes. type ReaderSize interface { io.Reader - - // Size ... Size() int64 } -// ByteReaderSize ... +// ByteReaderSize implements ReaderSize for an in-memory byte-slice. type ByteReaderSize struct { - bytes.Reader + br bytes.Reader sz int64 } -// NewByteReaderSize ... +// NewByteReaderSize returns a new ByteReaderSize instance reset to slice b. func NewByteReaderSize(b []byte) *ByteReaderSize { - rs := ByteReaderSize{} + rs := new(ByteReaderSize) rs.Reset(b) - return &rs + return rs } -// Size implements ReaderSize.Size(). -func (rs ByteReaderSize) Size() int64 { +// Read implements io.Reader. +func (rs *ByteReaderSize) Read(b []byte) (int, error) { + return rs.br.Read(b) +} + +// Size implements ReaderSize. +func (rs *ByteReaderSize) Size() int64 { return rs.sz } // Reset resets the ReaderSize to be reading from b. func (rs *ByteReaderSize) Reset(b []byte) { - rs.Reader.Reset(b) + rs.br.Reset(b) rs.sz = int64(len(b)) } - -// NopReadCloser turns a supplied io.Reader into io.ReadCloser with a nop Close() implementation. -func NopReadCloser(r io.Reader) io.ReadCloser { - return &nopReadCloser{r} -} - -// NopWriteCloser turns a supplied io.Writer into io.WriteCloser with a nop Close() implementation. -func NopWriteCloser(w io.Writer) io.WriteCloser { - return &nopWriteCloser{w} -} - -// ReadCloserWithCallback adds a customizable callback to be called upon Close() of a supplied io.ReadCloser. -// Note that the callback will never be called more than once, after execution this will remove the func reference. -func ReadCloserWithCallback(rc io.ReadCloser, cb func()) io.ReadCloser { - return &callbackReadCloser{ - ReadCloser: rc, - callback: cb, - } -} - -// WriteCloserWithCallback adds a customizable callback to be called upon Close() of a supplied io.WriteCloser. -// Note that the callback will never be called more than once, after execution this will remove the func reference. -func WriteCloserWithCallback(wc io.WriteCloser, cb func()) io.WriteCloser { - return &callbackWriteCloser{ - WriteCloser: wc, - callback: cb, - } -} - -// nopReadCloser turns an io.Reader -> io.ReadCloser with a nop Close(). -type nopReadCloser struct{ io.Reader } - -func (r *nopReadCloser) Close() error { return nil } - -// nopWriteCloser turns an io.Writer -> io.WriteCloser with a nop Close(). -type nopWriteCloser struct{ io.Writer } - -func (w nopWriteCloser) Close() error { return nil } - -// callbackReadCloser allows adding our own custom callback to an io.ReadCloser. -type callbackReadCloser struct { - io.ReadCloser - callback func() -} - -func (c *callbackReadCloser) Close() error { - if c.callback != nil { - cb := c.callback - c.callback = nil - defer cb() - } - return c.ReadCloser.Close() -} - -// callbackWriteCloser allows adding our own custom callback to an io.WriteCloser. -type callbackWriteCloser struct { - io.WriteCloser - callback func() -} - -func (c *callbackWriteCloser) Close() error { - if c.callback != nil { - cb := c.callback - c.callback = nil - defer cb() - } - return c.WriteCloser.Close() -} diff --git a/vendor/modules.txt b/vendor/modules.txt index d988d31e6..745bee307 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -24,7 +24,7 @@ codeberg.org/gruf/go-debug # codeberg.org/gruf/go-errors/v2 v2.0.2 ## explicit; go 1.16 codeberg.org/gruf/go-errors/v2 -# codeberg.org/gruf/go-fastcopy v1.1.1 +# codeberg.org/gruf/go-fastcopy v1.1.2 ## explicit; go 1.17 codeberg.org/gruf/go-fastcopy # codeberg.org/gruf/go-fastpath v1.0.3 @@ -36,6 +36,9 @@ codeberg.org/gruf/go-fastpath/v2 # codeberg.org/gruf/go-hashenc v1.0.2 ## explicit; go 1.16 codeberg.org/gruf/go-hashenc +# codeberg.org/gruf/go-iotools v0.0.0-20221224124424-3386841cb225 +## explicit; go 1.19 +codeberg.org/gruf/go-iotools # codeberg.org/gruf/go-kv v1.5.2 ## explicit; go 1.19 codeberg.org/gruf/go-kv @@ -49,7 +52,7 @@ codeberg.org/gruf/go-mangler # codeberg.org/gruf/go-maps v1.0.3 ## explicit; go 1.19 codeberg.org/gruf/go-maps -# codeberg.org/gruf/go-mutexes v1.1.4 +# codeberg.org/gruf/go-mutexes v1.1.5 ## explicit; go 1.14 codeberg.org/gruf/go-mutexes # codeberg.org/gruf/go-pools v1.1.0 @@ -61,7 +64,7 @@ codeberg.org/gruf/go-runners # codeberg.org/gruf/go-sched v1.2.0 ## explicit; go 1.19 codeberg.org/gruf/go-sched -# codeberg.org/gruf/go-store/v2 v2.0.10 +# codeberg.org/gruf/go-store/v2 v2.2.1 ## explicit; go 1.19 codeberg.org/gruf/go-store/v2/kv codeberg.org/gruf/go-store/v2/storage