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 @@ package dereferencing_test 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 @@ import ( "os" "path" "testing" + "time" "codeberg.org/gruf/go-store/v2/kv" "codeberg.org/gruf/go-store/v2/storage" @@ -33,7 +34,6 @@ import ( 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 @@ package media 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 @@ import ( "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 @@ package media 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@TkzVPIgaWn^Mz zU=d^$QZ#gA6AnydS1J@SYMi)`L)mHLLD8U#A5@H!ikdjN#Ka{erBv0_H8izMOwG(K zEUlbfT;1F~JiUTLLc_u%BBPR1Qq$5iGP89XZ3R<7E#dCS&q+js2Tb?ESsqsNY)IC<*Q&8nOvF2C^p> z3M&~ka)>xhT)6Qdr?PR-2hpUWi(FzVCJ$9Vg1iLw5pf=BBFjf`55fKO{}uxeGb00& rAhRHYJ;TRY8jBjZM)7DEjHZFnG%%V5M$^D(8W>FjBPb2n|Gx>Qk2+yV?tjLghTEX=H|EG!HRjJ1qR%nU4otU`*0j%>n#iR?;+B1Vl97jh^& zZ9FI%bn%0VaZ*teCzqJGgrtbvx}>nyN9P&a7buactm7Wa!P7i zdPZheaY<=ec|~Pab4zPmdq-#2q{&mJPMbbs=B!1Fmn>bje8tLDn>KIRx^4T8ox2Vl zK63Qf@e?OcUAlbb>b2`PZr*zM=<$=M&z`?{`Re1R&tJZN`~KtSFGdDth<6wnp*}+L zk01jRBNGb?GYdP&Ka5P}AW1uqec!9r-=(U9^_Ou4*DRPRCJL` zOvU7(>PL{5z&;|*LrrA)2<{=cfBxTM;9+KDU=m~&WUyyQ6$x};9mS(zFq#HN)4*sN d7)=ACX<#%BjHZFnG%%V5M$-Td(}4W{n*gMLKxzO0 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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 literal 22858 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 literal 1912 zcmex=~qY?v?AS1INF0ES%nl09od8f6WNstMT{CJF62;l z+IUbj=;8+zR;@QBE$5`HGdRHf`Rrb=&qGJ9iyA zeB|h{<0np@x^(%<)oa&p+`RSh(c>pipFMx^^3}&rpTB(l_Wj4tUyKaQ5brQDLVbkf zA3+8tMkW>(W)^mke;Ap{L6U+jtcr$gLXLs#iG{*SMvWXIP7@byJjkhR9P~jnspuk? zn2O0m)sG-Afqg`rhnmRp5!^#?|NOtjz{AYQz$C~l$Y9TqDiY|xI*LcbU^ESkrh(Bk LfHMuq|GxPaDvA(*Rq99}g(n|;*$_4~Ohk&6;4K?)M zl-_FqY0`T!^bkt!>^tY&x#vFJHS>F0Z)^T**1}87i2;;flqt~PAX*xlYcyABuU)%# zgO>IN7;+0tM+fF$VrGEwK>2vNq1^ZG3(Jb%7nBmb$NfO{p_H70va&Lt*wbfE6g6d) zlohCGZ`=UWf!S}}VpkC07Et*A*7g5sEcwz#1svH6r2EtUn_$-Gk(+-XEco+gie)sw zdVH|@nU_rbY54E^Ls!HbINzQUtlHJ0gd$gIRr~ELb~4`J)Zf+DY2I?TU6bqBUx>D= z71IyS!bIr7j_wGuxNugEDRcZ}gYdI1CoM6zJ zQo+NU9(l`$^%Y1UJ@o&GjJ&E1x;*!e;BZe7EXeYewQKAQX8pbGba7?6xcf0KHTQri zOWq|{%tW|EOG{{Lj8@ym5>YwUJJcLx$2hm@Pv<>D0fkCisF4+LNgYeoUg<9xG}k+T z_S(d*w*W!3@DSnrWe2{hcXk_`-XI%aD<+1oI_syc{`RO!C(UnFzqVlzhn2f2smdj~ zd|Lu{b`hu1;#!YImuN1diKWZoEFDABdsqED7jcV1>{$hd6PcGL>NKI^0mJl?mvx^ zvATX-qy)1ftv)6kCO>MyHJ8?XRQ=f0QCEXEue}{;QX}j@vLka6Qg^V5`UR#NcWZr9 z-p5Uk4Qw(7x}F%f4SZ76L-b-5`zRpu&@Kw-dyGlEOqGmF0={thv+5|H8g5Ve$X*vn z>67d$)3C@o(;Y9+%36)fc`6?QM=VYRA6~87rta{$UvJDHSXS$(_^p#I;#72y0#dOj zZV0E~!Ldiie(F*AyeI)xgLNHK1=d~Q=Gjxiff{B!u&5xIjs0v*Wx{C<&^ip`hZm2F z1g<1M!YHE~Sm-mr?e*AS-ovwwtH6u7fFkf1$emqbujzS5{Lt ze(H*MUSG{4R7cR$(!Dh%y2r}6U&gWP-I=l66>no>jV9o2ocs|_g15%nEXk@rHn=%) zvx7j)N{qH>?|{^>RJ2@=BzyFS6mLgc?^RdcXpY`K0@muv7tn1T00cffB)#B$FjzX9 zuZXR%&{$%c^19!AvCzG8eg0i>5>2@J;%)GT0@|HylzP+a9=Bn`Xa@bgWzhi`1%xD= z!asmOpa(Sed%GuS-ee)46ACD5NCSKvr8WR7D;Ju-84Z5YWW6PHF3L2oZ3?1J zJ7i2Nke>NH$FTs|c6#$*W~$AZWh46gn-f-S=$&GAu{vUzR+DkOmg^f8-THC|k~=hb z+0wd@i(|cPKHqkYNboXN zWS9FG`ud6Gwl&Hj&*TYUd!NK_(T!4ys9v`e6Q1=EFh%3t`>_t*b!j1X*@6f4^-SzXAp*yF-eKtGis4C>g51MjFHtFYi1 z=x^nVxPGtaFxLL!bfdm^X?xq*T?DhJoge$ab(~yf66|kFxw0v z|Bj5B+zEaE^U&Z|#7A#DTFF!0bDa><@(ub3?n&%u&2G_`7agI1fDB97oe&il%h^;N zyl~{G+r$A%e^I9ff}s)8d8@#%rB1(Sq&F!GR~PDb$>dZm^2p>dX5MjbtFB9Z=2Cc& z`>C_f+RGKr`X{*HkUc`;>68FR(#P4HK191$N0#qW`og5GizgfkV>Co-1^>U z?xma`)Wum!!VC5UcYC=;TnQwnjaQVZ2UcLw6#T+DDB2w;waZG*oHS7I=Ly&098Ke_ zZg#IUGv#AAvEvQkUG|rwX8#nG6s7kz3@gmwzMdIkV*2+T-t7Alt9+74Xy@U4z;5;i z(mV6Ti@!ME3(IydUEwX=-t5;{g!!)v;629SDCHr{1`BUYSGZNX>php%59|@~Db0o1 z6;cuGH{Qa4*6?noq`X@oz5u2HU$;$||9E<-TUIm$^f;iuI@zN1#TSwziZn4I6znl1DkcN7Wk}E8>=t zIW@PohxI>i2Q9Sjy_`+NJ^7LT!(GY>!q0vZgS6Ykxdtw-R(o;S0iAw9h6hGbmd`9spU|9IV}+&-;= zv@X+E8ri-)gf6plzNzABG_Y7m0hNwtrnR6DEf<`>6UA9glICqhZyL9uRs2#XN3J>qkx1?+080B{m^@Mz6iph zkU)VBb4#K5n#9zaiCK-wJ=8oczYBel6VTqsb@Z%1CZn>)A|zq);g{B4aDO$A9M7il z%lHqD%+74W_}MxhkjFtM4GrhvsyqeM(xC8!7+afAU(FhqR}iN1ZLe(U9o1A!hhLn> zaT{x<%5?{Km4|KH?Oc+;Ew%(Fa%2Na+sJ=Ay60us{3j#oX7=_VHO-mh>H)bvA;6AV zkhT5Z+kCdC-#a_5{pwsgm_rHszYcz7;H7eES|p}YPFFN}F|y)Z=STtd%oh0!6ORgu z5KVN+Td#XZ4HMnsrY`$y^u^5DZEim1Fq^O|ycYyR*R_`XCEf*-qh{ICu`FNAG?lVP zN6U1G71+4^hzi)ZRZ_62-`6KOTLU;RtwsH&#svEkHCyi&O@c8jQzI!}1Nwl_#szfN za)T3GmU5oFd=jg9Zt>*S^}~aK3NHB@hz|~2CS2N4*&cY;a95+T0i6;vk7l*Xc?|d7 zt-GFHA)xvcUO}6(^54qEZq|6FmcTM69NIfV_&@5 z;O0Wb!%0Y>!$@QKL)wi9xt#5dc2PI=%Wd;YXoyAGh?Sld8r^EHn=EF~Z$$=Tz}?Q( zqQTTB2~(S7-2 zMD)mCeRhmcF#R%+X3qeAZq}&$o!vO*W^T^spza6V#Xp%o#*4((UC+nakhZ6Tot*%6s6+jLgDd0iIVnm0T(u9?pqW=b4L3ST5qT{V zvxN+lFtz^7kAVXwi;qpegz)4Xyg213hcy?Bo61 zw^euU4Jb*--hNEJFr1C#e6xT8FTze$g0`ejPfr{Zu5D3GM&&9xuCkM@e9lqy+F!Ma z{DBVN8*4Q)B0~7MlJ{hu{a`0A{xam`BU5nKt8(?wjO}pgCAM1lN;qqar zMD05-f^|x@Bby&cc3DryOk+8JI71uwGrbW^SYi6q7=1zi*Ihe?Q$VtJWk4o=-8HaUC+nIcId>eJuzgnKjjw`m)}b zKR^X347pHC0nwALazKq)+jelSxvz8Xy^o!{`I(-Dm#AZ4c<2=D3IEMoGq!aG6H66Y zvVvWo&iDYCX7@3uWf&l(K2`>$47?sG`n_Wyi_t$}elnO85H4@cLz7am;o*9o*L@>7 zP;qVaC?!8C3C`j`?Bi<)-Lx1NmyY0F2CNhB>6G+5>?m&Y1c5-*AUDQD7N+IqNmCsc z3djR8`Q(6Fdf${Ugm)=L8yVlKt!eigTt`*HH^!xdnnhAT9CVf}mW{93RC{WAEH3OJWi4pRF|mRKze>we3G?M8NE*SOkwVz@*7k? zHaIg!F8LV|#s`$y#%+i@RaeuPO%1o>Xm-;!REh|+$EU(QD7Aj!9@EQ_y=hUw9}zw6 zdTy(IL&;KE?r(hr(yPPN{<(GkgD`ztBTsP4D-j_6#S?RtE+>`?{zEbtM*$TXL291C z9Pe-82NX&9gGw5 zV+#!DtsmW5f8|h_N<*&u&`iAkmD? zb;&5dU73qyz9#>(wlacQ_V02pcJ-NYa)eVB((voF5uKS<#n?et`Q~V^e)zSGu!-Pm ze3Fu_*1zswg>eLt0%~viB@3OH3;<$qy%RUR_e_q-oD@(h-n$Q}R!=<2iHC^-2pex|=uTeAkn2$y>U0~mFwVEWd7hKR96GDqR&4gQ>W+N|%d<%V?W}w$X$#{4*hp4mW30wa>s{+#RMS`@ zaK|fjv$Zz=x4Car1P1bHLHdA9#sVR}2030S`~7tek!k0IYkr1M!s(bO(#u@D)O>h) zDc2woRt(SeeL(zK3HWawM=d-vEX*rjYna_#Q!3juAOEDtesQni)MAop#9?YxNpb9A z?xN}3=g5l5NRLq{r#S}#@-coi4T~>}=&jW~ec=+;XJBCD5#i(RO{4kmr^WDP!jfCA zodEUSR!+{6U9ltwaX6yrL2t%NSW^oD9!d&m@d*wB{)9Db0X#t7H@~HTUlfoQnJR`o zJkFThK4MsgR5*33{zT3Pr_3EGA5H|fX<;G^ZRPDPasBSD+M`7W$S7Cb%rhr`m*Aiy zo;2vI&9mpkZt~BRRrMJpV!WFI!sL>GyYmATANwQjlxh})k$~gR2fMU{F9#f;RC6x zyul&kXyt@(laV5b!MXV<#>d-mPVSZIR!K^`}^$1(S^p) z_}wv#wJcoshtm5<@qIi}pY(OU@X;-CKVWQ;BpUArK{ zry|2(;~}siP>(#PfxbGpLiNv_1)B5FAf+oG-#*MmmS60D(BI@%Ff&84X%>odn^=W- zq>Dcm>b@_h^xMLGGKFR(U006PW;^??oGrMzlb0&qUgMW|O)& z*o#=Ik={B@9E-V3#)jB1adzFyd${q@=XEnmW;%Z(et4#s0>U`ciz@%Ur?YO66&8qz zcN9H1H7QI#2y;$#L8cbIVu%?J{rFgEGdjyYPWirldPeb&tUQ=*(<^tT5Y_*V{6Dde aAwt-$N2orBf$q~}iN+8i%+xfLiT?t%`vIE( delta 6027 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 aAwt-$N2orBf$q~}iN+8i%+xfLiT?t%`vIE( delta 6027 zcma)=XE59kyM|W~ok)mI5)w6{tzLq#x*$prC5RS;=%sK!xisYfY=&xfPh7^9sH9;5k-D z!r0$n)kD-JD9(Vsw53WHxaq?3gc3X=uc^`_YNC?y*3vchhT`j*(n7%jpwipjmv2&J z-1KO8M-XkY_zac-ecau~ZV3`7fc`I%k_sP0FeP2rSDU!?ZN*rUMP;H+Q57Agf=ap~ zoXx(L$k+-xPn|PMD>tZ0yHhhSkpk_AZ&F(}wCwM~GFKNiKJA+vDkX!bt0IJ_I&7=l zEMb7Tk2Pzr1O-$8|Ae=ciwI)hJ1mYfX>6YHh+tfLbjSpdMIKV)Y|OS~+kPz{?*-wo zK5slfsT&-cwofT4cqMqE^-+BAbMxk z$5IRM^W%6DUgUihwv!M;KE+NTw@dCQZw9nXwQ1%yaR>(S!aXeYf@CJMlc1W%a^Y1c z+st$6*CJ!5qS9BPvs_q;fok1cfFVz}1oih#V<7juS-rzERCIIl-`jk>_WjcSJw94sA#D??+ z2R-W)+Yw7BaQcP+>vWYjTN>zv7JY}7NQ#Y*-}U|*^8WhT)v&Kh{$E6PS&iEqJ6PH* zT%)hCAk0VAWZPKe%PLa-?6e190>B=i;%cmix&i0wWMmL45#*G3q!=c=(CFc1fThp< z`s>BFOkUI@P1w2Eo_k-fy_-?^-=)#AXC`xV1^JML?rjru!8{`kCeT0JdYAfC^0vxQ zhUVb6En$Y&YE-_4@~H6zE$8%arG2^(B6`QMr)?HvNp7HYnM&yikXt7 zya65By1(yMr=fXxj@->(o2eG6N2&^sE0UJ%n9GN4boSC`I<6@CAX$?Fj20PZx6KIm zF;}w23YTtTM*uui;nh~|{!SQj;N%7fL=quSVq@T(sir+%gxI`-81PVE310R&CA@QR zH0@z?vQbCMDx-?sj5I<|HU--U7Z0LjyDf#fHJF}dFq1FEm*kVYKbqnAnE31qY2v`t z$(h-6N27pW%{TW}}o>fxw85ck2hOC~MC%Rv;0Mi6XzjZhNYF2(KA;S3JiP!c_ zS9!(h%+M^wm<6|$j9^=RRxY9OmYEbE{dnlyT*FHBjQZ(g z(vyKUR7(x2p9rFJ!fb^Z>MSdNg6A+iqL`fyy9mG`6cC=bN^MvyOMIkO#>x~W(N;Xn zF53X`%D?5zIRc)^f1><5rqiPjzjA{3tSF3jAEVwiT3)z04$lbGMn5}H?uw~H6rJpA z&YDkp;5gXGGA3)*m2>mXZv-M}*rI%${4fpm=>~o^f6|!-7I-PjN@p3%{?!SS7tM%H z`TVqvy6wlbBFTlryyWpr4Ru?fT^la+3&2w!u*}?#y%AZ@q|y^viVnU4Zvl+pxAu9( zAM|4p1{_?4T!1pgrj4$xVTyf_-R^Ke!HMFchgH`T=ItvG28!Ygo+bpPNAJ4Kg^gbx zAB2Ph28P|1pjp4ETcREhv3$Q{#(`Vsq&ABn5J-%iZB(v1gi^_d2wG>=kvRp{nT#|- zySK@ij2{0_=u)IIq!XT)$9J2Vv5|4-LC<#4kauHIIL08y58LJ<{sBj}HK_eF`5;am7gOGH&yx^5*hhI)>=JZ;bh0znA+t`x>IY7=u6G;4`%BSUhedx z(CJ#STg%=#e`n-Pu({}0OT()KlYP$Qs?}RVn}~*@>Hm) z59up~*N@>CZX_NH9x>TS-~6(Xw6PQ?j^od)D|w!yZra$ubM)H@27hcog+LZl{wye* z5Wo~I{#kxI+d*DL_Xs66FdTb81ch#PF)Z}D7y>HT$8IbLklo)J>x?J!zyBZ||af@}Li42PvXC4J|?JQHywGvn6Nhq3pMz?62-yHaKo`b@F1-bdM-*a*$H zKth;=P@EfkRQ8eXn?yb{>AD|JZE6=SzWy*+{*3*J5!Z;=+p4+<({!&qTaBLM2)ut( z^rfhKDZ+q-GXdgl$rOPdi_g4aUN`$v&yxt!%uck?;kl#?zt$1mm4p5J8Ei|sQ|mEY z^uQ6f2$$ipcEBP&S8hq{WS<1tszWIU^)NkAhFsGU+dh%UZS%33CG;UX4A-$Nf;={sHmfMi!e<0yx1&&b;k#% zgrqgx_qTr21o=CGJq&?GM9@$7{kobtw|IXbdClmGFU;^&0d%v&^?JMCYHM|eyxOu} zH_h2Q>bn=cx0b)I*^-4sr50=@)=v)WkN3W0tlFbTjZ#wZIp#LGY~R-0@Fdf#7yWIf z`E()tV1=H8`wSs~xNniW+-W;aIup?#^3NJ0&_1sg|PmOJ-u281X(wz6c^H1a_RA+gM)UitVlY zXi?CMDRZ@6QQU%&bWDWgC)Rda8nsKMu1=)yKZ4-b6gX@S|d|QZ&0ekGP-x)ohMt@ z#A4FHl>#_hrk#LeHx?Z}M{Up?lE~`ReP#_;J^h_gk9Z%*2>x( z>1Ch-l3_kDYc0CGWQW@;>09g3g6lxM_lU}8?jM<9slPe4rp~H-p5M2t@`j~TkgatI z?FX8}LY0wpxG>(4e>eu3y}i8O<~f0hn5Y_^95lg6g(0fUMkI-?|_=j^18Wg5*f+0 zu74(rsNgxwJcHTMH2`PAoahFz|&;7sB6F~EE(Lz{A1W85iZBBLX<$Pw;S z-!!M+9|M++xnJ%0?UY)pI)F|6DY^NO*ifwyq^z=3R9n!WDSbLIrxNIvxwg>9{x&sY z)PV@E;5Z5Kq8X8j<1{LF4v?j$FxHot`J*tb<@r)B!O`4gPt%>EuZyJx>zS14Ygy@v z4a(YBxJ?8}(L=-S`+^AM;o;LcUk*7KR@zHHS!b3yLx_c)noPIcf$@2trIWDl3+1o``gewJD_q`z#QpV7%RYx98B zCH7PGpV#5a)erK0iiegAKI8_0-uEUcBhzKpDH8DQs8e~0`B34~z(nII=XSd~*Zk&w z_FH`iT5?LY6vknfhB`4ic)yhe-7q!mU8pGE=lAME^ZX#%^R+osFIqgglz#BxizzU` zKMmG>@2&8C*FqpXt)+q2tbuz)e^nHUt8|E?{@@z(EqBDGpG7s=NssIOC!zU%JiP9IDB z(3UulY%qi&koA&85O?nLzQs+i<^F8|_7|Z#?(6=LR6A0?7FOKBOUi#g(Z|q7pKgmH zhu~eC8AhYu75}`jDJMhE_vwlq{JS@PHq4={^rNE0r%cl!Uh4(8&|}FBIh(aJcxAv=kXmG4N(0CfAB4))BJ_1=BqclD%Z1ua7sVUGH+R(&dsR5Wx>TMA)u!*fBde9fV49ai!D^(mR1I>}$8~ zwNEB^3f=?7p|IYD+x&dlskRWp(`n65E4n{9ZZ4tcl}^JB3BKS^VA*EWoJP&~z~?1* zIr|lJvrPEI@fA{q!+T?sZ^pJW&*QJo5b&;Bu6-B%{>O}I4Y8YE!!DN=JUv|zJd$Dh zZ~ypoYH$xs(swSTCV+|ypvQDQmB~$A(N)ik6IAmU1h9W&yon$x<6&nnKBVX4ow{;| zmN$zG->$%!SFCY5z&p=S?ec|fZ^mRQWyevaBp})5(;7)750$NS+#8JTliZhSpay}B zH0S(2?*t$6(BXeSMW?s4rD}X8w7!2yYz3dRC_1?{KD#%uG1*Q8-N)yKS&G3Hf)~#p ztr9`8C-JX_JY;k)v~+37FN`?W)!|tQ^8@{!!u5y@MPUHVMtFp#3De=M7hI6CBZ8`6 zm#deM7Uq-7C|q-?J^6l7fmP5?dD#QHAXb7xsYzBLVYEW>Pkj4rfdc5N^nc97KL$gf zCZI1@FtR6@>?_Q)rfA6O2@ro79$8KV?M|!F`xqtc=@-?E3E}(EBxQl`xqy2VKcs2C zhy_Y~wHE9;KiyTa%h%t`_li7W8Ub-QThfE~aH>+(`LiI4Q zn1eR2hYo#N!Qa=up=EXkFCkwix}uWb9y9bcfOELV{(UNrJfrPV^kqw*L!di7XfC$p zQWN>4O*3Uvgc{y`Z5a;{MK*v1sU|jG$0(lBhY&$+Ay?t8>jVI@y1PufdvklX{P>ah z0L8G7zx`EURpvQW5A*i?&X?Y-NM&TglMHZ*jJYOBdfNJp;2W;f$NlfF_-ydj#px>{ zodR%N(Any#-d`ff`OLgyTYeN?Ggo|&<@@P|jGtop32&KlUsniYR1QVW(&*eX6OA2# zg4XzJP=UN_PyrzC8dNwN3yE(8sK15J%cg9>imM#HD7$$;j|M(TTJbd~6=4XcUAVaXqZ@Nq#g;c4z%bF5K3{5NitqKf1jfi{Npj zG)#RRvkn7H4vCgld zgp}d9h#+2DrNFG7Hh7dDsXM?j(0gW5Dky-Xm|ZAClyNKi$eSub4C<%%Z2FCwb>p0O z668Z7=Psc8J!|(NkSe_jW0TqPqbQq+rW}ZRJBy{$dh=v}K%o5=L`^9^AY2%)TjpAI z8hrcWLb=P3(42_5dW3?z9mfK^fkiIsnh=v{GuAujj5ho7jk}Pg00=Lv0H@@ohM&Jj z9`|CBFxrQnjl}1#9r144yeq*IK|SXch~z4(zD>L+L)!ewSslEm>t#W!o70qYm9Hpx z{i|{p)TI=rO&fcU8wR6P`B*wTKzR@&@S}8!^I3I>?`~7gE8#6iS)j%_-5NIBoh6`tWS7hQ_n}UjpI8L`R=(P%gZ=^q|kFn3+ z+w4E;_?<&vf48x*d2%nmzCOynIm*AEJpokdT_UICqJcH5Whe*{ZLHuTfryve{oWuU+w8X zX^=;(YBSYhcY<=0F%G)TPO0XQsRy0*gHXne(x1871aX_FQrBj7jk4y+*Q-q*09swx zMd}p%tvQ)qFxHT 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 @@ import ( 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 @@ import ( "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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 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 zcmeFYXHeA7wW?(>I2m+D?X2_sKB{QgGNrE6rqC^Qp4kAMoM&cl#>tjq#!INDJ>@_$9eCG>SGy| zhq7|gpj)J*Fmf0(B_*>o4+oF*e_H;w0aU~wIA|IS;s6M!Kwv7+-!4!N0DuTV{}SNe z1VjLa5E4O&Np6u+0t6s1m;eGMB!oZ!02BZs00R&zLTYwl1)|$J7ElfpjYv>@HZlBB zRXgpo(PMtM^>r;Dw=)mGvtd zTRVFPcMnf5Zy#U3kkGL3cM*{oY{G}cq~wpEQgU+h@(T)!ia&p;uEEvT)i*SDbar+3 z^!D`+jEzrB;-{u(W>;3%);Bh{ws&?TA*%ohy)SSJc`e*YA1n>hj}Hmo+4bJa0Kgl zpP7oqL&GK?Cv+c2je3<`o`G>=DcX1oHx7;T&*(xjvD{%VRl7d^p@)h z_R_860fWJW;AA)|P6c{a057YZD6M$;q^3F8D`HiFCJ2-HIHdc5tg&XwLFwApTK~ko zTLYxAos#9Gy5qOiF41fwPPw?0wI^wWVUOOqDvcqJ(%en1SjcZUv*+xodSuj+QPvz( zQhiK6!-hnNM6o{IfIqeAT+=|L%97jkoat4W1BOsI0|Q(F%Hp7L-?Vzdsq8N>=7c|dp|4+O zs9@a(Ei@s!GWbL^{^`c+>gglthvSr81hf#!89w6W+v9clb}#K8FnT5jySzT&2;Wry zn7FC)&b7afyLh+-@Ve5%)O@$qZLP$8^u@cqmPAEL^z(-Fv3xhHo2=Pu3a_jET9;yd zJ(`zQ*=vG!M30z{3CifK?n$o2;8#9H*T=}HRnh;kq6f?Nw;dTS(kBgS;%k`rUyX@> zoWVTybfm)U&YN%Kfs6Ltk*pOc6nUwjcvmrvgUHZ6aQB%*G2UZyeB*1(1&Pxtq$ZM$ zPe?Rsi=o_jRipwC9^$=5|4qf)`C`YnHjGvQ8OM zSRvtHx0isE-25be1)~Glbi$)Wy3FDuIAlacH zS1q_pxS9!VDQ6e(7ZA3JZB#DqRlC?@6%g$GEn>FLL(om5gCzV zB~`ahir1PaGTKv62{EglpTS#DI9zYJ6A2IRWZZuPd@lhZMtRxpzO~NN1qzdXyr{ZkmB=oABSuI`n+R z!-yXli6i!S;jU)}rl(X(Sy#7GADoLvt>;?D+b~7y#PJ0M3!h$3RdjhjDfNpZsgSDr za_5eAPZsqd5%JH>78WF!pfS5DG+dq8on6(+kmfHy3Mkx=Q!6rmhQybk6+mb^5h%kW(ZV-=c=z2pQIn@h`j70&juCFxst9lx z{W>}@Uw7=BH+%GKn=N$SWS&2c9#j_58a)p~#kHC{$X8fW*GUW3nnuBoYD;{N?guEe!l`N%fy5Hyj->yPZ_=&n#D zw-CFI_?;b1w{{XFDX5JbNZAyd`&A}bUU8VcBsRrG^ZS={su#iXG0$p0PTD_!AgXkw;2%k@D>F%ONbj%y-sU95wPCddg!a9n!PA*M-buk=&0iT{A)vIOQ z*M%8A59bxuTUGe$g(MD%cgcJX8JNLw;U?Cx_{KBE zC&XEV+5otch4)Mwq)78$0)bW9*r*~vqoWAWjDa9jY2!s%6jD=Sb>rtyB*lmI8rwNp zd01lc3R-T^HP6%hZL@)T(awm2R{_Jj#>(Bo34NCEWY7Gvjw&WqLK8RQ!+;WoM?LYD z>Z~>?T$Xf-%Zw|YM-<9TpE!br+54ej#9tuV%P%(5pNfHx$JOCgB&|EF> z!@0hh>h~z67($+D5DxGSaP(ZxXg>eI+TzRFk@ZgF&QA(NvO!)WBy|X_#G=p#X0uvi zam>#7yguFYKq|UyVbGAtoqXIy&VsFH8i)hdQE4dc_S}~~fyb>2t(*-dsza%(knz;mb7MB8Q~lMgQ*P7K?O{oFRoVPe7y+@8qC)g{ zM+?TzpLvla-fZSnal(6xb#{fWRbei<^NK9k>H??344WQbs28wS!I9<>`4?DW$<%^n zypCFDP9tl8pNZoX+0zc@3$-Q-eO#P!M0>*Z8X#^&U|wccDNO9s>Zat!p#s__aCQDh zlY264FXGykmw)aoHuAE^nD|Cig!Je!>wRo%D@45*8ZG4^)EndgLO7qE)7@Ob(8OC zWK>J0>GiUm-A?gLCBjTTtWdH@_?0r!O{1&Uam$FO;J>=Z{tI>=fSPAEN&h`?;w9+@ zHqmq#o-6_kFG;TvV8kn`2nf=A%FiPBSK&Vu|2wFGz5{~)?n#8MqTP|C|9)Jsi)j;# zD$)Qt{|la*Dx`Z{hsUfqr73kiU8a2;ljjXCGb;T`9oSc0{1=$Y`+7x^MJk1gubHz+ z+PG(J_n~GRFeCvB0L$PBJ*n`z{GF#IMP_re$=5lC0K)%PZ0FrmzMZ@0YtTPverN}s z{%l%fc~tccq5Y%LZa4x`N<0O4T{?WFl zbKH-2_#=2c(`qKG8DrytiaR*9a+UR!+-d>57`<=>4s+p|fi51pN5pw#$`iMSjuX!( zf0yU{)D8nlt3Z@|6AE*GsZ7`20N3zT$NqK#s(n;;-s5g9j>S;2!W_ccR{Xa zYRf${xn9+Jylu>$wx%S)l1-V9WV!D3vS${BCa$@*MakcA?3EJFw3Fu*<`+c- zc8+uW96y&t#?I=%f{qa)k(J1T;b;Hs-1r8VkKcA=34MlmWAUPiAdr$!x|@}!_IR>} zTJ3~^>l3BYsS~Fv=%YX2{=7_ny1kXe<5bU}Dfp_Xvvf_H@ZM*&4T(f*%4`UV0>|_d{d7nF zRTnbDetNo1`@tu`4R7;(D;D>EV^iBuGhsKmJd-!s8H^^hwGw6h!2n~ zT)Cz8vugaicSelfN$3g$xt|RirQlCzuNIsIxnq1%b9>rCE-}kz*YDqF-rlSG;^U{G z|DybRjazaoWlxp`(yGTDt89YL3>R=Yc%vRDvVD}lGo@YfOJG|$14atu1;r@^bOWC9 z4rtT&W|EK%vS5+7mRZYbe%2VR+BwqUDu%Ud1uY@Pdgr+`Th>pdcd3Ic%C`ObmX%$d zxa9ZMghbEGzouJA-@R0geXlBKs}Yi z@DGCjFF^ci!01xWQ9^Kqg6z;TIvWm=01lz&=UK2^-IJK~{L7ee)r{2zNZ)~Ia~6FB zPy}D%!nxq0A3hD^YHbakeRy5Q9|qmYzf}rP>s~%`#Yc|!T zWM|4C+SbJ%QgTyOj?vPI7TyMEhu*-H?cc32cBJ_;m(9$I#FH%?tGdqeY+YnJ2RZzHH0H z{lnbcUu7^;aLnm}q09of3l+}a)J6P|x1GUyopl1VR2bpm{q>r_A*swWy;bD{-Ee{E z)ND=AYQtUnZ9i+)TdhJfouo#Otb;}Y4^OsW;w?$$v>yp6J08CnO{Hd)C2hm#6XxSc zvToJMag3Hxy9(021fSr?Pt~$C~jL zDBI6kC`X!8jiUKn2JHtllE0MvV5)70_A=01`=fj_hBB*qet+4Q?HR1+s;-Co|y>eyUlI=y%o&k<<1BDIX|3jR1(;|CENNXz$fBcUbSxA!H4$* zX+DXu82LJVaC>6UK0x7=7p4EFQ06WB3GAH}$&w=@x#nv(VlWl>+)`@=$<#P;YHa5{ z-Z6)WSy-U=gz&Qc@%)s3nX2{f$=FcI=ecfgloL~B3bM2-Xh`JwOO zkqMKdpeBCR0xz)v`D3`*{D+$UhI0S_Dv&tqyOWBn#XV_a}tbN(pRI=8y;cQfMG4|{&we?AM z*%^|oM00#41lsX&6xrqaOzj0eSq`_O;~i$TGZaQhEE(iu@bZbamsVbp#qEJ!YSCU} z#>J5`zMs`&doAuJ0?wBqFMKM_B3`=yOT}d^1KH5v&_ucPXpfxc#YL#8Xy?Of0NTu-}0fM!6vrxxvNf3Bo{8%?P5<%B4{SaamXuGbWGgGSUZMD8qbTXNK#<9 z%R5Be$AOks|E=Gt9-$uSOa587&EABE`y@wDzr&xd6?1wAr)^kZK!{{fm8~*a%$3V{ zqj##DZ|ZQBMzxYbH#BvI_)Y1est+LCIP@p z7+@b)<~L%`nCHb9#dWpVzxh16-J=7`bk{`Jqoiaod<>DG`~EeaK-I~R=vNxrJ7fZs z5GU&pVxQy}*y$MJLIAJ*(7k@^VT15G)(@*yrc>!o_jRQo=r<+lW34B)1DCd(5XBcf ztu%Ac$DdY05ZEx8vo&!`^*t3Q{Hk#To|}z7i5`(g#`;T^AAOuQKA1x~Bg1_!U-nw2 zdO^DDX;(t;H#dM8G)HUn7ts8QJ2pK?+(=8(kN%8wV)q4$p+w8|D1Cs0*ry}VT*KWY zsgQvrZ`XM-@-I3};nM*yzK!QKRW+5NiuO)4!~lIXu(KC~bNG*!--X39>5tVLzfe@p zKROAAOZ34dZX7mfKd-Mm%}S=uSfB zjprY4vcEBJTn;9o@7qS1%F=-PXD+4GogVA7rs_DRrYn7G7mNAG*)M~J0bl?$?O9UT zsiU=gc_ZyEN@MI1I!ND6hzbX`vVmp%ZyHda>7}jNq{mFnciop5b=!!-L#JAqW^)(G zosl@%V3r(X?Z0aMhIf?h+sWT?zxNA2f@W*}r^MkWkhOOGiX=Kt5e>4Vr#taG>ZDBdqF`7 zQJzQop1+y*^e?JNLd|TOG3!{-diEiC#;F{%WYbp)v(6H&;%%c)FRbbEe5cGy>t~cg z^ne1CnVLe}QE9aNwKI3i*9q2(i)BJD(l25>m99)7xX1k~i7vX9qd@DbE(ZHO)!S=?oG$U(Vu`h5K|vf8VeK{ddo^8qko#Th^xf zgr`m8N7&gP8jmnziY>Zh!(ty&-?cW)U-WS{W@Wk1|u5(S=eTijvGViWUbvS|hhEj2+2_SlzJS7rtsgCVPK#tBSR%Z}>RI_PJdK1oo;_X@ z;Kt*WNDwdH`h%gD6XpXs1r706=Ab1jE+6eHzq_rXx@DmHcBBp+E2fq9>(dU_-=min zvdD#oX8NmdS8SX>bD@-JXr`Ya;hX@hF; z!O-$&JmyH*%U$2SotnrHT)eweIvV3tBkpwf8=U{-%3K{vL_A--Q zKdyY*>X)H+gytV_!fENrgcP96R@t93kHdye9jb>vGaQPm->p`K_eMSZ=fnb*K|A=S zTRdRCHC0icS7(7@1Q+2;-+J$QJEN{vKO0>-mEmMXejc#5wp%*gI=Eh6%V_r4IWi@r z0S$1!6A$1s%o*{tbK2kZiZh;{`xQ*Bso6o?HY_38Ef+{E)<`lQlPxgb@0F};@=e4v zBwPZFmS_V9nJUtN9$hW|aV&XQ(?WNm$mWh}%ix z>oSPm)_|sTUc7}*I(vNG?{wNo@GMe%ecdh(r-n%`U*mlyJ-^$&m~av+NoC@x*?EF6 zc{J9~Z%*w>-0=hL`yKG4?u%#lWzkW7)&Dq*Ewn=6OEvPx zerZ1OsjDiCsccPSMgpGg+*r^hI^W(u znPoU6dG><3jSVVei}g7`G9@Yv>(+eD*k!U;Z^>)b>$GPwX6TjmIV+hA%i2w54Euv4 z>kz6nD4?&sM`DWc-AYK9!T<|74NmTcc(xPH@YsCLD#s>Yvp2gcQN>_XI|NNaf2gwg z>0WV#Y9MQfm3Zn>Cvnon$tnj=JAy|Kib!K6Y3l|&13pXSt14|Tn7S!#utjafn|xy| zXmR<>LZO5GwqVjR_F}R7HpH@i;LGi_sI8E`(yx%#GOHE!>;8KOdBJdx(VohcEZ85+;E2oSM6;|#AgV?k<&2dZ?NNa&+vdB2yBoiyaJ_eTA#AQQiax9T z7N%lCu*}+QWrz5&h?Z{TkDkBa!V7kZ^uj^)N1%@>|TIz~Fxqi~K%)qE=yygL}F1#(QzOg>4{}b_0 zx3)4;NmX{2M@HAP;6b6|Drba=J$`k=rVhf{r3`0dj#HqA%XR~v;YB+}`L$J+_XSDm z-#5MrhdqywFA@)ObYJ5wO@8hdb8DW}0y+GtY1vm_dB=4j6%s4?+Se_LA$98Rbz z0pA;PV3IzfbF2-~8_yfxFXJ4XQ$UeQ_octDWV`3L48{QaJf(rKJ#Le+?yk60|A=)v zXAW*TlL*%~)-{=sdB&mUNu1-+Z4DyMh$3U)a1Op zu-tg3ZcBUY=DO+eb!IgI^*&Xc9<|C=FooJs>%<%5oSo>>CAm}q$|9`4#Of=Lr%_>U zDhTjAfI_k)()OF}H8!_$g{X{S=Q+Q}&O)lQyI zk;?RM)}UV>KfjSJjdoX2?+I-evccHB*s{jBcFO)@3E)pjTGKU)l)@DxGIRKr9?TS` ztEzl(UPmh+63`-YHK0Wu6I?+>$xh=Lqf)a|fqIu0uBk36OCVPuDld)MjaX)a&L}hD zMww}8M|666=Z}?ORee~VZ=1xtD-wu9O{}E=`#j~mH^xQE`RZa9$DO_fX1zqYl!Nz^ zy2y8ikZi%CY37rebUqnJkDJGHYN8R!E)S0D5xoGrIvHz&#nlK@Z=Db82TCc-+x5EY zNu~XrZ3UT`ePMpAA`_rc`rBBqA=YHvqsdI5Y(QPG9nNW~xW;^}PAPi+`7b~bxUM|j ziVfWE&4Q);k1OpjH>!UD4t!y|{5JMjenGrgH7d&LM-~j;*(y9P_LX$nXpcITzsdi_ zL|32c2SR$@pdSRn;|<-faEa%2@2sW@zkE`~_Y?}&oAfgy;v`tw*y3Mebu>k?)(ViT z=@{V05ra>O2L6pGXKlQ2i!GKbKKF4g4U9xUgrTE57b6`~}W#n;_iMA0m%m;t1>t{XUKF9PyLm=jq1MxZ8M}Gkge+{`!CfkoRSXHb0IO6s;i2E=@@mY(U?0xmx z@m0>;x=5>M$_P+YW*LBLyNwf zWp0hXo9rJRKbSsz63z7VMxD zKT7MPUiO{cMU~mCU>!Bn`_TLO@N{;6+<4roAk@1ZW|E zwapSOna+IoQtJkZY7d`-{s9~^nk;8yqiw!Cu9@E!eZo}9*+x5KPM`wju4tiW7$OQl zA(@@uuL<6NI&!e@>B~5%T^(tWG&7QHnG0lrH=)c_d(X{BU7VY5m&|_$D|q|6b7eyW zght~Yv&n^qF|eL5e4P9!{2bv8PkF`z4jn(Z+s#Ug$2sM5O)`C}6%f)L01sYSe8}v{ zKgL&++Rs)*d9oaLO6NrPsj94Lw?JhY91hTcIUu~v`yk4xt+zlT3lgK4#2F8J-ez$I z=V+)P2^NpNZEYp>srUD_(Js)<2H1ib&az-O*W9c6SXb2VboU|oIkldtn%{hbJ;{vk zA_Ny|t}o#)4j)Uruzng`QFBbp7VNXybVJ}C6M#|xh0_zr_Sk_jBZ4=gs8yc)k(!<~ zW0;7=!%^2fXZqTmWFXd18#DnL0AT1_!214; zXMtQgv(sjI0VVr*l*Z<-^apD!qDvCJ;s)(0d5rhb*-aDup#kN3t~9kQ1#D!XUB4;E zDsSgm(Y+YES>Y%$KPwF2t<&wQ|MtmMEapa9tX1(aSGuK92>$IcWhXd_s(n~|aa^sK zJpvN*ju`{2)tE8Z`#DFtSdtLu$*TOuhgjXo`=_1j2g;H?;Piuuq=;-eBYY zqD56Nj<-c+=&UPdKnvf6HBw z4QqY+R}lZG5olWe;g<}SDa~l_0B=uW)35-WZO9Uue-aG>UW${;6YJ&DnN-ERud9Ua~~H2^*ZW11+GOpfo5$-HliMyt1s)r67Plx z)D$^+4tjr?IM7y>p;i^Lv1sQ3b%LX!{H6+*Z6z4a^LX+@XdyA7F?QfP9bS0+6$h^T z!}dG*(!X z>nXN8l#Eh=8Rl*}@VTf14b7s@v)1p~fU<~)Vu01N77>lN<6YiA6cAL7PdIDb?%}74 zaV9Y{aJbwgG;BGLX2!uFxv#C?F2!X?NI{X~k^Hi)e3*BA9somAWM#pa+TG?IrXKnw z&I(~WCdEdZ&6AN&LjzR7a+6jaQuzh(%Np!Qrn!cBLIspq(>zxwIJV4-4K!jUllIJs zzGpOO#1hTSYtL&3mAZ(y zLg8VfVn1@x%l*35&vwGS8*mskDbHMse(Rww8NHcwZ@ zL=xvMBhsYNN+k40MprNMtw#*!?iF9m*hVxzQ$_#ak*Nj1QCGKssl0i z4pp}ec72&95Ej}B67O7an8jQwcNy^e3#BaS+vT`!@dS-HK`Yzk(*Zx2y?w-6q*hjG z$r0_gk<-$Qi@3Uv4gGxB7X!Kaoy!rTZdxPby%(>)@7D=)ekot$b)}>*IqSADzWmzx z)AhRmBd#eA5KW)vBk6JV{U@w(e)gP2-EiC^0&*3$uHV;t%e3;vHLUQsV8&tgZPhEp zmFJnR5{T%DxfioHw6H=9l$|z)=SnRxoZWdK?}*QBvLD5mN)U6+VKt#(@Grd-7mS#O z(fZsA2Ujg2hSSLg_yl+&fCv2YACD~oj&F!|?@40hju5En-wLDC-rUHPi~uFd_@#|= z1_?qU9zLR-;&HoWLUP7)X6 z-$@>O;qhgl{PO8LKzNt|SVc==`1#dOxS~r3j(29G?ohM!E4qB7FkwXD8Nz@4R; zE$j#LvUM88w-Le|6jJi@R5Bh<8ChPSVr5Ir5UbA(1gMHfue={jEmQ9zk?kC)bl{Q* zNeeYaFZ+=tAX%s+azYHHoH|MyQwK}RW))rCZiUiifj%HSdrdNpfT_IF_jhUS{1TUD z1kR;>Z6s%dDveoC8DGPUC#~&jQX8x+qvE9bVOe1aD&!r}6r*)9E&3h(43@E;s?sNQ zZgX&zS8u8io>p^m=i@C0HD2KdThUG(N1}9~2WyhUPqhg#{BX?^rN|N!CVv)9l1;Fj zQ5}K;>Gy}$k7>?0HS%Ef6RWWeDIa)%T151md4w>9uXsX`?o|D;6v=AD)18dxqEVR+ z-|A12-#LYHIa$s?wpm4tC3Q#AJfKT7pe9B4m%JyQ74Ocj{X^g>#dIp=}_3 z;FPgHOJm_l;!0n`UdO6_sw_H$9rGKsi>D_Cgt0Uxxrf{8zSAOoplm- z6FtuU^{?eQi_z-rlxkDPg%W0g-HN}MEv$Wr_{iXMLB0i1N#U@CM}BtwVs*rQheCH0G%;V-qF?yeDP2MA%4+Zi`$o1g9J`ae6eQaZM8g^kWWm1nD*1+ks? zxDWd+vfiH9td{cp8By3U-en_v2U~O~jArRYz(k{LnJ3FcI|d6E>~-H4#O>fU>_Ete z!3y&MQXW~i-f{->$QZ7q*2f`+JrS^7KpXwbx7c7aJ*Zu+vX>`cuhQ@Q={OGDpL`I# zqBkb~>0GOJ&>iIt#CUxm>syJur?Tvd1jqowm-sVJJY65dQ?pl1>hVG8bMC#h?s&9F z4O)K1+~<8z*MszyjEc+hcAPxflCko)0*({EcV?dgb)F_8(`BA|rV1*Sb?ruagCeB9 zQKp$^=q<;`8~gMzWKqaZ>iO&l9ISG`W;AR;uqM=Z1xB!cH^EKZ>1y11x4}kOTkZJk z-K)50^xa3Wqk&o(@s)@-5-x9w7hP(;w|K{%+lk_!mHjt6Vs}XuvKMwAl89+K~Nz zAXNj;g_V{}6&5mp4-RMbxA$sGlw+nk{9@AYPS^YeD8IWD9CDgd!ez{{Y9;Y;k`fYq z2ddDZEZkUbO>JuiiPPcY?(B-B-Fwzh=5YGutuo*-Ypx4wRYT0t4{_z3M_QKG-r38} z=Y8*FDk_a?4&MYgrq>=Xtj{KvT~wEd|1hlb_rcOe()O`6fDvi8qteLE%B1<#n;cX7 zoyDPzF}~iNOl$;%ZA=O*zFR3k&q)#C8ClfUi@LkTc@si%h#hdypCDB1cSi%m(PV=M zh;LTcEbHEIur!8wZB7nH)U@&!IIwqe5p=Px{927@cf~LPQM7zMY%vocsmNWAS*uBK_%HLtbyN}sg%1?0ApJA@^4})VW zQ?cpy?-Tz_MOrN}BBYPuz%)CG{@ZI1#5`o)vf;fM$*zz&McP}=Pw_r6jWs)%z_ zK>_QNhZpDjryMhL#yt_Vb!2_*Qp>gpZD-#BkH)acx*JZN-|tnE^(LLl0B#?ZgV7iT z#4$;)vnyZN`-RNAi>YODvT~|5!AHz)?_iIYh1o#ySbm4UK&Q7>$%B%--RQyvb?R;j zVSz}Vm!TmeQ5gya(>_mU9jP6DGDb}q=+374nof{y1VKRPf8KPD{&BhGhDd))w+WeK z`Me+cRv0(^3<e7Ruq&#A3M|{AB1wJ7BGWLE;Ku+mh8^qHgPH|MtQl>E3s;Y;crNCqZE_ak1s7 z*JYUayZZZVU#|JNMnJzh=`PkierAzHyAdBWyRXdEYa_-PR^mUHH@@zJPC-zHtg!k? zWA%)y`xJ`4f*aq;#0M8RYCyIyTp>cxcedOF}p{`llN$3_fw)OtA+ZXJPy;z2w`4ZEH%D- zc^IHM`xh{tT+(@9jca~HixGQmg#sTjdklle(!Ezwi_Rs_$!|Y*k~026-ep52;3h;* z9x}2PtgzJrSk3Ra4z9L%E7s_+wC3gujP-=v;u6r4g935TDO%H$H4W)UqlH6wod5f; z&#j3#p-aBp$K@qYM((Kirxur6w|s448+PXbYKZ`VjF1Igbf!h7kmJypc;;S{>6GBc zPET2kNM!B8+JQ4`TN2Dz>8dj2UJLwKsK`|~HUDiAVzfvleDQ&KK5I-I zp7my*9TfsY?rI~&K=A=@s7ck$XncHT<~qgRKHta-T8(T6sldt5X|VKT#}d5!I;mga z&Z5?D2wouL&O`-X+R5rA_M)G~G+W&>Vn8n#T)7^FR-qr_&uXr_qpv&XJh$1?A17r^ zm5E17E)nc_>G&{dzTZD-oHs5}Z%|lN*^=DbIU{KJ{lHX89&Get$ymcLR~O5qE)nxI zA7S~~y?)kn^%n@`6CbmziTPt!?o`2gxVS@O z^UzY0SQ26$r?7{l|HIudi3p(cPrtvssf~8Fuy;qiS(l15#Kb5iM}O;e?E+$JMu#+V~Q=bayE} z6W$0FY}mGnEHSgSHX$$l-3Y0AXnamVK&w}w5BOuG2SFcbix2_5W=UW|H3%L=~|dH*8RqObk6cdrAm zObNMkf`C7S7*Q!vNNby#gZ81c6{`;$Gka&9YW&=GNt|qcQXx-6O*haBeQa@3el|6K zffZO-v#4B@3xf64ph@0t7X5sGv$=ac&_CzEhP(vuR5I|oZ{vUrC{U4;;G7d0!#Sz2floUSWubiSw zYLf6?$4vp7F`Uod=H04O1h{zZ{y$3GbrgDzhyxf9X&}wderb0d#+})IK;BxvZt7pQ z_7nrrwl-LJN_EowvK{KsLtpL%E0G&v59tf2q|qCsLHklEo1VNCsT$Q`rTSo+6T)~C z$}UQxHpzC@`nEC>g~paiPFN9p-6`!HngoK?%Ido*o;DDi+pE3vEYEddXt?9g0a`vr zSbx0%!;9s1Wksrm%!3ks>2t{C<{8h3)d^{VMIMdjSlP)Cl%=KlpO+}npR+h`yh0wg z1EC=!Ns{te1n33_*_S%&Wf%UEPrncsCyOYedwHP2aeA*t;h>w(dyUXatgY#%e&Gj1QWqtE-dN5ZFn#r|ej8Ag_UC!(=LCOm zB5deE!p}N{R}?FB)m08X`TYqfe*x4+`Bm$u2AIFSVy6xk-|O~JwZr*Yc5D@#)`5S| z_pksLgR}`@we)6SpdIc=zAA{cwGOV|>3M7nVmWhmR`$TvB{u3}lys zA^N|~4EbN}X_<2RqGG`O1jKMjT22~JvQyTKu?BMyrv=qFl~cMGf6!;euLYtWR4b!! zv%W0ry^j;dPuoh)K4y^(2HYB2wA(Nf4^g4%sb;=6+ml8iW; zV2zE;{71K~^2#-Rfx+_x%LnVBeNP<%9-Wn`@ethQ;MngG7udtaj3%+Zn{d}FtKsK2 z9MhxD{CVCr7qMve5Uzd-{z=BwRSTB< zBC3`2A%Ymups8&h@JO|iBTG%oqpi-Q3z7d;e7S}*p#Wfbv$=Bx_u#2^V08G&F1qVjz6dT+aD_Z*w1)TB$GKCr;kTF(zi0G1&%fKbM? zUwBfT*UG*ZRe3Q`+NNzFvwfgM>SP!N^L|V(v)L17tx|~F*HXC2-M<;R*WZo z;YQ4xB1~y_hsk99#*L02 zUE>{dGdGB^{Sbm7TUZd>kLN3puA$4OCIomiXeVLON3NiA`SVxUVvVZogIJ#n#Y|C- zlB`DYT=Fh4biZ>wsGx$xwa!rX1sVJU$TaVOt-|*x>(~g=l^HCc{|RgtrNlXeu)n){ zkLvMd!gHWZ+6|zS=4#O7o1F8zu-$q17Ih#zVZxTr3lV2844n^BQo_I-h*LM zTUHVtUhBqCbnfYw-}H|VL`DJo_cwzyICfpOy$m1`oyEWsA{pa887{~^V^|lMfExt; z%s~2~!yt()r`&d;&6;G=d#E!`a9i+>N_ZAvyF$S);dhU*Fs_Zc#2jx=;9%GuG4?ik zSx}XmD&NgSFjj^Be6L;$+_64-?dmh7t8HCYZnJ8z*v|RzRuUS(MwP`6je%*&+L*x2 zd7sNA&3~J1v@bmQ)5)=Ugq0|_>C6369g*ED32U(f3PAv0=hJ}1n!N6r$zP1L^|OY1 zR3{1p{D+wG3;vYgGkS*19~;i#cslDYaPZJ==~0Zj zE=pKZlZ6qBmoSJ)O6%0Eh_Cgq*$U*;F*wC4NS$p*^+{qrk&LigtEwt{69N>r-51V; zL}6vliuaXv782$jMSn?~s6i5IXE70Q5m4EI#~@Ub;|*P@@w^n1*{Wjz{74ziq0$mDPy3&s`C-GXEeTs+Rqq!`M88MEA|3pex z>yGpg3dfzNN2as(ah)Y1R6DuJ~((myfCe$eqAjTp=Gt zUzwOFw|&>>65`YNJU9BN>VJeu%L2?VjebXc)Q*vodvN{vOADxMN0C0c+LS*oc$1Ie{j?0E!?t!CMO7=`V7E_ z*%1`L;gCc4jL}&3VIW-V!gfGd=Mo4zcw#LM` 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 @@ import ( "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 @@ import ( "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 @@ loop: break loop default: st.bufpool.Put(buf) - return err + return 0, err } // Hash the encoded data @@ -469,7 +473,7 @@ loop: 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 @@ loop: }() // 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 @@ loop: // 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 @@ loop: // 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 @@ loop: // 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 @@ import ( "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 @@ import ( // 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 @@ outer: // 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 @@ import ( "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 @@ loop: // 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 @@ loop: 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 @@ loop: 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 @@ loop: 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 @@ import ( "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