diff --git a/internal/api/client/followrequests/get_test.go b/internal/api/client/followrequests/get_test.go
index f2fa832a1..24f3c9646 100644
--- a/internal/api/client/followrequests/get_test.go
+++ b/internal/api/client/followrequests/get_test.go
@@ -99,8 +99,8 @@ func (suite *GetTestSuite) TestGet() {
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
- "statuses_count": 0,
- "last_status_at": null,
+ "statuses_count": 1,
+ "last_status_at": "2023-11-02T10:44:25.000Z",
"emojis": [],
"fields": []
}
diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
index 3f0b9dc0d..928d70379 100644
--- a/internal/api/client/media/mediacreate_test.go
+++ b/internal/api/client/media/mediacreate_test.go
@@ -216,8 +216,8 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() {
X: -0.5,
Y: 0.5,
},
- }, attachmentReply.Meta)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachmentReply.Blurhash)
+ }, *attachmentReply.Meta)
+ suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", *attachmentReply.Blurhash)
suite.NotEmpty(attachmentReply.ID)
suite.NotEmpty(attachmentReply.URL)
suite.NotEmpty(attachmentReply.PreviewURL)
@@ -301,8 +301,8 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() {
X: -0.5,
Y: 0.5,
},
- }, attachmentReply.Meta)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachmentReply.Blurhash)
+ }, *attachmentReply.Meta)
+ suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", *attachmentReply.Blurhash)
suite.NotEmpty(attachmentReply.ID)
suite.Nil(attachmentReply.URL)
suite.NotEmpty(attachmentReply.PreviewURL)
diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go
index 603bde402..a0ff1b96d 100644
--- a/internal/api/client/media/mediaupdate_test.go
+++ b/internal/api/client/media/mediaupdate_test.go
@@ -186,8 +186,8 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() {
Original: apimodel.MediaDimensions{Width: 800, Height: 450, FrameRate: "", Duration: 0, Bitrate: 0, Size: "800x450", Aspect: 1.7777778},
Small: apimodel.MediaDimensions{Width: 256, Height: 144, FrameRate: "", Duration: 0, Bitrate: 0, Size: "256x144", Aspect: 1.7777778},
Focus: &apimodel.MediaFocus{X: -0.1, Y: 0.3},
- }, attachmentReply.Meta)
- suite.Equal(toUpdate.Blurhash, attachmentReply.Blurhash)
+ }, *attachmentReply.Meta)
+ suite.Equal(toUpdate.Blurhash, *attachmentReply.Blurhash)
suite.Equal(toUpdate.ID, attachmentReply.ID)
suite.Equal(toUpdate.URL, *attachmentReply.URL)
suite.NotEmpty(toUpdate.Thumbnail.URL, attachmentReply.PreviewURL)
diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go
index f9f1c95e9..1911fc9c3 100644
--- a/internal/api/model/attachment.go
+++ b/internal/api/model/attachment.go
@@ -70,10 +70,10 @@ type Attachment struct {
URL *string `json:"url"`
// A shorter URL for the attachment.
// In our case, we just give the URL again since we don't create smaller URLs.
- TextURL string `json:"text_url"`
+ TextURL *string `json:"text_url"`
// The location of a scaled-down preview of the attachment.
// example: https://example.org/fileserver/some_id/attachments/some_id/small/attachment.jpeg
- PreviewURL string `json:"preview_url"`
+ PreviewURL *string `json:"preview_url"`
// The location of the full-size original attachment on the remote server.
// Only defined for instances other than our own.
// example: https://some-other-server.org/attachments/original/ahhhhh.jpeg
@@ -83,13 +83,13 @@ type Attachment struct {
// example: https://some-other-server.org/attachments/small/ahhhhh.jpeg
PreviewRemoteURL *string `json:"preview_remote_url"`
// Metadata for this attachment.
- Meta MediaMeta `json:"meta,omitempty"`
+ Meta *MediaMeta `json:"meta"`
// Alt text that describes what is in the media attachment.
// example: This is a picture of a kitten.
Description *string `json:"description"`
// A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
// See https://github.com/woltapp/blurhash
- Blurhash string `json:"blurhash,omitempty"`
+ Blurhash *string `json:"blurhash"`
}
// MediaMeta models media metadata.
diff --git a/internal/cleaner/media.go b/internal/cleaner/media.go
index 9aca7aa20..6db205d13 100644
--- a/internal/cleaner/media.go
+++ b/internal/cleaner/media.go
@@ -290,7 +290,7 @@ func (m *Media) isOrphaned(ctx context.Context, path string) (bool, error) {
case media.TypeEmoji:
// Generate static URL for this emoji to lookup.
- staticURL := uris.GenerateURIForAttachment(
+ staticURL := uris.URIForAttachment(
pathParts[1], // instance account ID
string(media.TypeEmoji),
string(media.SizeStatic),
diff --git a/internal/cleaner/media_test.go b/internal/cleaner/media_test.go
index 20a643ea8..c1226bcb5 100644
--- a/internal/cleaner/media_test.go
+++ b/internal/cleaner/media_test.go
@@ -304,7 +304,7 @@ func (suite *MediaTestSuite) TestUncacheRemote() {
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(ctx, after)
suite.NoError(err)
- suite.Equal(2, totalUncached)
+ suite.Equal(3, totalUncached)
uncachedAttachment, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
@@ -327,7 +327,7 @@ func (suite *MediaTestSuite) TestUncacheRemoteDry() {
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(gtscontext.SetDryRun(ctx), after)
suite.NoError(err)
- suite.Equal(2, totalUncached)
+ suite.Equal(3, totalUncached)
uncachedAttachment, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
@@ -344,7 +344,7 @@ func (suite *MediaTestSuite) TestUncacheRemoteTwice() {
totalUncached, err := suite.cleaner.Media().UncacheRemote(ctx, after)
suite.NoError(err)
- suite.Equal(2, totalUncached)
+ suite.Equal(3, totalUncached)
// final uncache should uncache nothing, since the first uncache already happened
totalUncachedAgain, err := suite.cleaner.Media().UncacheRemote(ctx, after)
@@ -360,7 +360,7 @@ func (suite *MediaTestSuite) TestUncacheAndRecache() {
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(ctx, after)
suite.NoError(err)
- suite.Equal(2, totalUncached)
+ suite.Equal(3, totalUncached)
// media should no longer be stored
_, err = suite.storage.Get(ctx, testStatusAttachment.File.Path)
@@ -424,5 +424,5 @@ func (suite *MediaTestSuite) TestUncacheOneNonExistent() {
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(ctx, after)
suite.NoError(err)
- suite.Equal(2, totalUncached)
+ suite.Equal(3, totalUncached)
}
diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go
index cef0617b7..403a3888a 100644
--- a/internal/db/bundb/basic_test.go
+++ b/internal/db/bundb/basic_test.go
@@ -121,7 +121,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() {
s := []*gtsmodel.Status{}
err := suite.db.GetAll(context.Background(), &s)
suite.NoError(err)
- suite.Len(s, 20)
+ suite.Len(s, 21)
}
func (suite *BasicTestSuite) TestGetAllNotNull() {
diff --git a/internal/db/bundb/media_test.go b/internal/db/bundb/media_test.go
index 691c81729..6237d96b1 100644
--- a/internal/db/bundb/media_test.go
+++ b/internal/db/bundb/media_test.go
@@ -39,7 +39,7 @@ func (suite *MediaTestSuite) TestGetAttachmentByID() {
func (suite *MediaTestSuite) TestGetOlder() {
attachments, err := suite.db.GetCachedAttachmentsOlderThan(context.Background(), time.Now(), 20)
suite.NoError(err)
- suite.Len(attachments, 2)
+ suite.Len(attachments, 3)
}
func (suite *MediaTestSuite) TestGetCachedAttachmentsOlderThan() {
@@ -47,7 +47,7 @@ func (suite *MediaTestSuite) TestGetCachedAttachmentsOlderThan() {
attachments, err := suite.db.GetCachedAttachmentsOlderThan(ctx, time.Now(), 20)
suite.NoError(err)
- suite.Len(attachments, 2)
+ suite.Len(attachments, 3)
}
func TestMediaTestSuite(t *testing.T) {
diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go
index 7f274d693..80346412c 100644
--- a/internal/db/bundb/status.go
+++ b/internal/db/bundb/status.go
@@ -316,6 +316,7 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
if _, err := tx.
NewUpdate().
Model(a).
+ Column("status_id", "updated_at").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go
index 562062c8d..d551c3f0b 100644
--- a/internal/federation/dereferencing/account.go
+++ b/internal/federation/dereferencing/account.go
@@ -622,21 +622,16 @@ func (d *Dereferencer) fetchRemoteAccountAvatar(ctx context.Context, tsport tran
processing, ok := d.derefAvatars[latestAcc.AvatarRemoteURL]
if !ok {
- var err error
-
// Set the media data function to dereference avatar from URI.
data := func(ctx context.Context) (io.ReadCloser, int64, error) {
return tsport.DereferenceMedia(ctx, avatarURI)
}
// Create new media processing request from the media manager instance.
- processing, err = d.mediaManager.PreProcessMedia(ctx, data, latestAcc.ID, &media.AdditionalMediaInfo{
+ processing = d.mediaManager.PreProcessMedia(data, latestAcc.ID, &media.AdditionalMediaInfo{
Avatar: func() *bool { v := true; return &v }(),
RemoteURL: &latestAcc.AvatarRemoteURL,
})
- if err != nil {
- return gtserror.Newf("error preprocessing media for attachment %s: %w", latestAcc.AvatarRemoteURL, err)
- }
// Store media in map to mark as processing.
d.derefAvatars[latestAcc.AvatarRemoteURL] = processing
@@ -713,21 +708,16 @@ func (d *Dereferencer) fetchRemoteAccountHeader(ctx context.Context, tsport tran
processing, ok := d.derefHeaders[latestAcc.HeaderRemoteURL]
if !ok {
- var err error
-
// Set the media data function to dereference avatar from URI.
data := func(ctx context.Context) (io.ReadCloser, int64, error) {
return tsport.DereferenceMedia(ctx, headerURI)
}
// Create new media processing request from the media manager instance.
- processing, err = d.mediaManager.PreProcessMedia(ctx, data, latestAcc.ID, &media.AdditionalMediaInfo{
+ processing = d.mediaManager.PreProcessMedia(data, latestAcc.ID, &media.AdditionalMediaInfo{
Header: func() *bool { v := true; return &v }(),
RemoteURL: &latestAcc.HeaderRemoteURL,
})
- if err != nil {
- return gtserror.Newf("error preprocessing media for attachment %s: %w", latestAcc.HeaderRemoteURL, err)
- }
// Store media in map to mark as processing.
d.derefHeaders[latestAcc.HeaderRemoteURL] = processing
diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go
deleted file mode 100644
index 15aa4bb08..000000000
--- a/internal/federation/dereferencing/media.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package dereferencing
-
-import (
- "context"
- "fmt"
- "io"
- "net/url"
-
- "github.com/superseriousbusiness/gotosocial/internal/media"
-)
-
-func (d *Dereferencer) GetRemoteMedia(ctx context.Context, requestingUsername string, accountID string, remoteURL string, ai *media.AdditionalMediaInfo) (*media.ProcessingMedia, error) {
- if accountID == "" {
- return nil, fmt.Errorf("GetRemoteMedia: account ID was empty")
- }
-
- t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername)
- if err != nil {
- return nil, fmt.Errorf("GetRemoteMedia: error creating transport: %s", err)
- }
-
- derefURI, err := url.Parse(remoteURL)
- if err != nil {
- return nil, fmt.Errorf("GetRemoteMedia: error parsing url: %s", err)
- }
-
- dataFunc := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
- return t.DereferenceMedia(innerCtx, derefURI)
- }
-
- processingMedia, err := d.mediaManager.ProcessMedia(ctx, dataFunc, accountID, ai)
- if err != nil {
- return nil, fmt.Errorf("GetRemoteMedia: error processing attachment: %s", err)
- }
-
- return processingMedia, nil
-}
diff --git a/internal/federation/dereferencing/media_test.go b/internal/federation/dereferencing/media_test.go
deleted file mode 100644
index 0de3f294f..000000000
--- a/internal/federation/dereferencing/media_test.go
+++ /dev/null
@@ -1,161 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package dereferencing_test
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
-)
-
-type AttachmentTestSuite struct {
- DereferencerStandardTestSuite
-}
-
-func (suite *AttachmentTestSuite) TestDereferenceAttachmentBlocking() {
- ctx := context.Background()
-
- fetchingAccount := suite.testAccounts["local_account_1"]
-
- attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM"
- attachmentStatus := "01FENS9NTTVNEX1YZV7GB63MT8"
- 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 := "LtQ9yKi__4%g%MRjWCt7%hozM_az"
-
- media, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{
- StatusID: &attachmentStatus,
- RemoteURL: &attachmentURL,
- Description: &attachmentDescription,
- Blurhash: &attachmentBlurhash,
- })
- suite.NoError(err)
-
- // make a blocking call to load the attachment from the in-process media
- attachment, err := media.LoadAttachment(ctx)
- suite.NoError(err)
-
- suite.NotNil(attachment)
-
- suite.Equal(attachmentOwner, attachment.AccountID)
- suite.Equal(attachmentStatus, attachment.StatusID)
- suite.Equal(attachmentURL, attachment.RemoteURL)
- suite.NotEmpty(attachment.URL)
- suite.NotEmpty(attachment.Blurhash)
- suite.NotEmpty(attachment.ID)
- suite.NotEmpty(attachment.CreatedAt)
- suite.NotEmpty(attachment.UpdatedAt)
- suite.EqualValues(1.3365462, attachment.FileMeta.Original.Aspect)
- suite.Equal(2071680, attachment.FileMeta.Original.Size)
- suite.Equal(1245, attachment.FileMeta.Original.Height)
- suite.Equal(1664, attachment.FileMeta.Original.Width)
- suite.Equal(attachmentBlurhash, attachment.Blurhash)
- suite.Equal(gtsmodel.ProcessingStatusProcessed, attachment.Processing)
- suite.NotEmpty(attachment.File.Path)
- suite.Equal(attachmentContentType, attachment.File.ContentType)
- suite.Equal(attachmentDescription, attachment.Description)
-
- suite.NotEmpty(attachment.Thumbnail.Path)
- suite.NotEmpty(attachment.Type)
-
- // attachment should also now be in the database
- dbAttachment, err := suite.db.GetAttachmentByID(context.Background(), attachment.ID)
- suite.NoError(err)
- suite.NotNil(dbAttachment)
-
- suite.Equal(attachmentOwner, dbAttachment.AccountID)
- suite.Equal(attachmentStatus, dbAttachment.StatusID)
- suite.Equal(attachmentURL, dbAttachment.RemoteURL)
- suite.NotEmpty(dbAttachment.URL)
- suite.NotEmpty(dbAttachment.Blurhash)
- suite.NotEmpty(dbAttachment.ID)
- suite.NotEmpty(dbAttachment.CreatedAt)
- suite.NotEmpty(dbAttachment.UpdatedAt)
- suite.EqualValues(1.3365462, dbAttachment.FileMeta.Original.Aspect)
- suite.Equal(2071680, dbAttachment.FileMeta.Original.Size)
- suite.Equal(1245, dbAttachment.FileMeta.Original.Height)
- suite.Equal(1664, dbAttachment.FileMeta.Original.Width)
- suite.Equal(attachmentBlurhash, dbAttachment.Blurhash)
- suite.Equal(gtsmodel.ProcessingStatusProcessed, dbAttachment.Processing)
- suite.NotEmpty(dbAttachment.File.Path)
- suite.Equal(attachmentContentType, dbAttachment.File.ContentType)
- suite.Equal(attachmentDescription, dbAttachment.Description)
-
- suite.NotEmpty(dbAttachment.Thumbnail.Path)
- suite.NotEmpty(dbAttachment.Type)
-}
-
-func (suite *AttachmentTestSuite) TestDereferenceAttachmentAsync() {
- ctx := context.Background()
-
- fetchingAccount := suite.testAccounts["local_account_1"]
-
- attachmentOwner := "01FENS9F666SEQ6TYQWEEY78GM"
- attachmentStatus := "01FENS9NTTVNEX1YZV7GB63MT8"
- 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 := "LtQ9yKi__4%g%MRjWCt7%hozM_az"
-
- processingMedia, err := suite.dereferencer.GetRemoteMedia(ctx, fetchingAccount.Username, attachmentOwner, attachmentURL, &media.AdditionalMediaInfo{
- StatusID: &attachmentStatus,
- RemoteURL: &attachmentURL,
- Description: &attachmentDescription,
- Blurhash: &attachmentBlurhash,
- })
- suite.NoError(err)
- attachmentID := processingMedia.AttachmentID()
-
- time.Sleep(time.Second * 3)
-
- // now get the attachment from the database
- attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
- suite.NoError(err)
-
- suite.NotNil(attachment)
-
- suite.Equal(attachmentOwner, attachment.AccountID)
- suite.Equal(attachmentStatus, attachment.StatusID)
- suite.Equal(attachmentURL, attachment.RemoteURL)
- suite.NotEmpty(attachment.URL)
- suite.NotEmpty(attachment.Blurhash)
- suite.NotEmpty(attachment.ID)
- suite.NotEmpty(attachment.CreatedAt)
- suite.NotEmpty(attachment.UpdatedAt)
- suite.EqualValues(1.3365462, attachment.FileMeta.Original.Aspect)
- suite.Equal(2071680, attachment.FileMeta.Original.Size)
- suite.Equal(1245, attachment.FileMeta.Original.Height)
- suite.Equal(1664, attachment.FileMeta.Original.Width)
- suite.Equal(attachmentBlurhash, attachment.Blurhash)
- suite.Equal(gtsmodel.ProcessingStatusProcessed, attachment.Processing)
- suite.NotEmpty(attachment.File.Path)
- suite.Equal(attachmentContentType, attachment.File.ContentType)
- suite.Equal(attachmentDescription, attachment.Description)
-
- suite.NotEmpty(attachment.Thumbnail.Path)
- suite.NotEmpty(attachment.Type)
-}
-
-func TestAttachmentTestSuite(t *testing.T) {
- suite.Run(t, new(AttachmentTestSuite))
-}
diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go
index 1f09dedc0..032238bd5 100644
--- a/internal/federation/dereferencing/status.go
+++ b/internal/federation/dereferencing/status.go
@@ -789,7 +789,7 @@ func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transp
for i := range status.Attachments {
attachment := status.Attachments[i]
- // Look for existing media attachment with remoet URL first.
+ // Look for existing media attachment with remote URL first.
existing, ok := existing.GetAttachmentByRemoteURL(attachment.RemoteURL)
if ok && existing.ID != "" && *existing.Cached {
status.Attachments[i] = existing
@@ -804,25 +804,33 @@ func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transp
continue
}
- // Start pre-processing remote media at remote URL.
- processing, err := d.mediaManager.PreProcessMedia(ctx, func(ctx context.Context) (io.ReadCloser, int64, error) {
+ data := func(ctx context.Context) (io.ReadCloser, int64, error) {
return tsport.DereferenceMedia(ctx, remoteURL)
- }, status.AccountID, &media.AdditionalMediaInfo{
+ }
+
+ ai := &media.AdditionalMediaInfo{
StatusID: &status.ID,
RemoteURL: &attachment.RemoteURL,
Description: &attachment.Description,
Blurhash: &attachment.Blurhash,
- })
- if err != nil {
- log.Errorf(ctx, "error processing attachment: %v", err)
- continue
}
+ // Start pre-processing remote media at remote URL.
+ processing := d.mediaManager.PreProcessMedia(data, status.AccountID, ai)
+
// Force attachment loading *right now*.
attachment, err = processing.LoadAttachment(ctx)
if err != nil {
- log.Errorf(ctx, "error loading attachment: %v", err)
- continue
+ if attachment == nil {
+ // Totally failed to load;
+ // bail on this attachment.
+ log.Errorf(ctx, "error loading attachment: %v", err)
+ continue
+ }
+
+ // Partially loaded. Keep as
+ // placeholder and try again later.
+ log.Warnf(ctx, "partially loaded attachment: %v", err)
}
// Set the *new* attachment and ID.
@@ -832,8 +840,7 @@ func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transp
for i := 0; i < len(status.AttachmentIDs); {
if status.AttachmentIDs[i] == "" {
- // This is a failed attachment population, this may
- // be due to us not currently supporting a media type.
+ // Remove totally failed attachment populations
copy(status.Attachments[i:], status.Attachments[i+1:])
copy(status.AttachmentIDs[i:], status.AttachmentIDs[i+1:])
status.Attachments = status.Attachments[:len(status.Attachments)-1]
diff --git a/internal/gtserror/error.go b/internal/gtserror/error.go
index 85dc0d54c..21d580c4e 100644
--- a/internal/gtserror/error.go
+++ b/internal/gtserror/error.go
@@ -43,9 +43,10 @@ const (
// Unretrievable checks error for a stored "unretrievable" flag.
//
// Unretrievable indicates that a call to retrieve a resource
-// (account, status, etc) could not be fulfilled, either because
-// it was not found locally, or because some prerequisite remote
-// resource call failed, making it impossible to return the item.
+// (account, status, attachment, etc) could not be fulfilled,
+// either because it was not found locally, or because some
+// prerequisite remote resource call failed, making it impossible
+// to return the item.
func Unretrievable(err error) bool {
_, ok := errors.Value(err, unrtrvableKey).(struct{})
return ok
diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
index e418de7d2..f18589f85 100644
--- a/internal/gtsmodel/mediaattachment.go
+++ b/internal/gtsmodel/mediaattachment.go
@@ -30,7 +30,7 @@ type MediaAttachment struct {
StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached
URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server
RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media)
- Type FileType `bun:",nullzero,notnull"` // Type of file (image/gifv/audio/video)
+ Type FileType `bun:",nullzero,notnull"` // Type of file (image/gifv/audio/video/unknown)
FileMeta FileMeta `bun:",embed:,nullzero,notnull"` // Metadata about the file
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
Description string `bun:""` // Description of the attachment (for screenreaders)
diff --git a/internal/media/manager.go b/internal/media/manager.go
index afe686cb9..dfae37d80 100644
--- a/internal/media/manager.go
+++ b/internal/media/manager.go
@@ -20,7 +20,6 @@ package media
import (
"context"
"errors"
- "fmt"
"io"
"time"
@@ -32,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/uris"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
var SupportedMIMETypes = []string{
@@ -57,52 +57,67 @@ func NewManager(state *state.State) *Manager {
return m
}
-// PreProcessMedia begins the process of decoding and storing the given data as an attachment.
-// It will return a pointer to a ProcessingMedia struct upon which further actions can be performed, such as getting
-// the finished media, thumbnail, attachment, etc.
+// PreProcessMedia begins the process of decoding
+// and storing the given data as an attachment.
+// It will return a pointer to a ProcessingMedia
+// struct upon which further actions can be performed,
+// such as getting the finished media, thumbnail,
+// attachment, etc.
//
-// data should be a function that the media manager can call to return a reader containing the media data.
+// - data: a function that the media manager can call
+// to return a reader containing the media data.
+// - accountID: the account that the media belongs to.
+// - ai: optional and can be nil. Any additional information
+// about the attachment provided will be put in the database.
//
-// accountID should be the account that the media belongs to.
-//
-// ai is optional and can be nil. Any additional information about the attachment provided will be put in the database.
-//
-// Note: unlike ProcessMedia, this will NOT queue the media to be asynchronously processed.
-func (m *Manager) PreProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) {
- id, err := id.NewRandomULID()
- if err != nil {
- return nil, err
- }
-
- avatar := false
- header := false
- cached := false
+// Note: unlike ProcessMedia, this will NOT
+// queue the media to be asynchronously processed.
+func (m *Manager) PreProcessMedia(
+ data DataFunc,
+ accountID string,
+ ai *AdditionalMediaInfo,
+) *ProcessingMedia {
+ // Populate initial fields on the new media,
+ // leaving out fields with values we don't know
+ // yet. These will be overwritten as we go.
now := time.Now()
-
- // populate initial fields on the media attachment -- some of these will be overwritten as we proceed
attachment := >smodel.MediaAttachment{
- ID: id,
- CreatedAt: now,
- UpdatedAt: now,
- StatusID: "",
- URL: "", // we don't know yet because it depends on the uncalled DataFunc
- RemoteURL: "",
- Type: gtsmodel.FileTypeUnknown, // we don't know yet because it depends on the uncalled DataFunc
- FileMeta: gtsmodel.FileMeta{},
- AccountID: accountID,
- Description: "",
- ScheduledStatusID: "",
- Blurhash: "",
- Processing: gtsmodel.ProcessingStatusReceived,
- File: gtsmodel.File{UpdatedAt: now},
- Thumbnail: gtsmodel.Thumbnail{UpdatedAt: now},
- Avatar: &avatar,
- Header: &header,
- Cached: &cached,
+ ID: id.NewULID(),
+ CreatedAt: now,
+ UpdatedAt: now,
+ Type: gtsmodel.FileTypeUnknown,
+ FileMeta: gtsmodel.FileMeta{},
+ AccountID: accountID,
+ Processing: gtsmodel.ProcessingStatusReceived,
+ File: gtsmodel.File{
+ UpdatedAt: now,
+ ContentType: "application/octet-stream",
+ },
+ Thumbnail: gtsmodel.Thumbnail{UpdatedAt: now},
+ Avatar: util.Ptr(false),
+ Header: util.Ptr(false),
+ Cached: util.Ptr(false),
}
- // check if we have additional info to add to the attachment,
- // and overwrite some of the attachment fields if so
+ attachment.URL = uris.URIForAttachment(
+ accountID,
+ string(TypeAttachment),
+ string(SizeOriginal),
+ attachment.ID,
+ "unknown",
+ )
+
+ attachment.File.Path = uris.StoragePathForAttachment(
+ accountID,
+ string(TypeAttachment),
+ string(SizeOriginal),
+ attachment.ID,
+ "unknown",
+ )
+
+ // Check if we were provided additional info
+ // to add to the attachment, and overwrite
+ // some of the attachment fields if so.
if ai != nil {
if ai.CreatedAt != nil {
attachment.CreatedAt = *ai.CreatedAt
@@ -151,14 +166,21 @@ func (m *Manager) PreProcessMedia(ctx context.Context, data DataFunc, accountID
mgr: m,
}
- return processingMedia, nil
+ return processingMedia
}
-// PreProcessMediaRecache refetches, reprocesses, and recaches an existing attachment that has been uncached via cleaner pruning.
+// PreProcessMediaRecache refetches, reprocesses,
+// and recaches an existing attachment that has
+// been uncached via cleaner pruning.
//
-// Note: unlike ProcessMedia, this will NOT queue the media to be asychronously processed.
-func (m *Manager) PreProcessMediaRecache(ctx context.Context, data DataFunc, attachmentID string) (*ProcessingMedia, error) {
- // get the existing attachment from database.
+// Note: unlike ProcessMedia, this will NOT queue
+// the media to be asychronously processed.
+func (m *Manager) PreProcessMediaRecache(
+ ctx context.Context,
+ data DataFunc,
+ attachmentID string,
+) (*ProcessingMedia, error) {
+ // Get the existing attachment from database.
attachment, err := m.state.DB.GetAttachmentByID(ctx, attachmentID)
if err != nil {
return nil, err
@@ -167,43 +189,39 @@ func (m *Manager) PreProcessMediaRecache(ctx context.Context, data DataFunc, att
processingMedia := &ProcessingMedia{
media: attachment,
dataFn: data,
- recache: true, // indicate it's a recache
+ recache: true, // Indicate it's a recache.
mgr: m,
}
return processingMedia, nil
}
-// ProcessMedia will call PreProcessMedia, followed by queuing the media to be processing in the media worker queue.
-func (m *Manager) ProcessMedia(ctx context.Context, data DataFunc, accountID string, ai *AdditionalMediaInfo) (*ProcessingMedia, error) {
- // Create a new processing media object for this media request.
- media, err := m.PreProcessMedia(ctx, data, accountID, ai)
- if err != nil {
- return nil, err
- }
-
- // Attempt to add this media processing item to the worker queue.
- _ = m.state.Workers.Media.MustEnqueueCtx(ctx, media.Process)
-
- return media, nil
-}
-
-// PreProcessEmoji begins the process of decoding and storing the given data as an emoji.
-// It will return a pointer to a ProcessingEmoji struct upon which further actions can be performed, such as getting
-// the finished media, thumbnail, attachment, etc.
+// PreProcessEmoji begins the process of decoding and storing
+// the given data as an emoji. It will return a pointer to a
+// ProcessingEmoji struct upon which further actions can be
+// performed, such as getting the finished media, thumbnail,
+// attachment, etc.
//
-// data should be a function that the media manager can call to return a reader containing the emoji data.
+// - data: function that the media manager can call
+// to return a reader containing the emoji data.
+// - shortcode: the emoji shortcode without the ':'s around it.
+// - emojiID: database ID that should be used to store the emoji.
+// - uri: ActivityPub URI/ID of the emoji.
+// - ai: optional and can be nil. Any additional information
+// about the emoji provided will be put in the database.
+// - refresh: refetch/refresh the emoji.
//
-// shortcode should be the emoji shortcode without the ':'s around it.
-//
-// id is the database ID that should be used to store the emoji.
-//
-// uri is the ActivityPub URI/ID of the emoji.
-//
-// ai is optional and can be nil. Any additional information about the emoji provided will be put in the database.
-//
-// Note: unlike ProcessEmoji, this will NOT queue the emoji to be asynchronously processed.
-func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode string, emojiID string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) {
+// Note: unlike ProcessEmoji, this will NOT queue
+// the emoji to be asynchronously processed.
+func (m *Manager) PreProcessEmoji(
+ ctx context.Context,
+ data DataFunc,
+ shortcode string,
+ emojiID string,
+ uri string,
+ ai *AdditionalEmojiInfo,
+ refresh bool,
+) (*ProcessingEmoji, error) {
var (
newPathID string
emoji *gtsmodel.Emoji
@@ -217,18 +235,22 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode
}
if refresh {
- // Look for existing emoji by given ID.
+ // Existing emoji!
+
emoji, err = m.state.DB.GetEmojiByID(ctx, emojiID)
if err != nil {
- return nil, gtserror.Newf("error fetching emoji to refresh from the db: %s", err)
+ err = gtserror.Newf("error fetching emoji to refresh from the db: %w", err)
+ return nil, err
}
- // if this is a refresh, we will end up with new images
- // stored for this emoji, so we can use an io.Closer callback
- // to perform clean up of the old images from storage
+ // Since this is a refresh, we will end up with
+ // new images stored for this emoji, so we should
+ // use an io.Closer callback to perform clean up
+ // of the original images from storage.
originalData := data
originalImagePath := emoji.ImagePath
originalImageStaticPath := emoji.ImageStaticPath
+
data = func(ctx context.Context) (io.ReadCloser, int64, error) {
// Call original data func.
rc, sz, err := originalData(ctx)
@@ -251,49 +273,81 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode
return iotools.ReadCloser(rc, c), sz, nil
}
+ // Reuse existing shortcode and URI -
+ // these don't change when we refresh.
+ emoji.Shortcode = shortcode
+ emoji.URI = uri
+
+ // Use a new ID to create a new path
+ // for the new images, to get around
+ // needing to do cache invalidation.
newPathID, err = id.NewRandomULID()
if err != nil {
return nil, gtserror.Newf("error generating alternateID for emoji refresh: %s", err)
}
- // store + serve static image at new path ID
- emoji.ImageStaticURL = uris.GenerateURIForAttachment(instanceAcc.ID, string(TypeEmoji), string(SizeStatic), newPathID, mimePng)
- emoji.ImageStaticPath = fmt.Sprintf("%s/%s/%s/%s.%s", instanceAcc.ID, TypeEmoji, SizeStatic, newPathID, mimePng)
+ emoji.ImageStaticURL = uris.URIForAttachment(
+ instanceAcc.ID,
+ string(TypeEmoji),
+ string(SizeStatic),
+ newPathID,
+ // All static emojis
+ // are encoded as png.
+ mimePng,
+ )
- emoji.Shortcode = shortcode
- emoji.URI = uri
+ emoji.ImageStaticPath = uris.StoragePathForAttachment(
+ instanceAcc.ID,
+ string(TypeEmoji),
+ string(SizeStatic),
+ newPathID,
+ // All static emojis
+ // are encoded as png.
+ mimePng,
+ )
} else {
- disabled := false
- visibleInPicker := true
+ // New emoji!
- // populate initial fields on the emoji -- some of these will be overwritten as we proceed
+ imageStaticURL := uris.URIForAttachment(
+ instanceAcc.ID,
+ string(TypeEmoji),
+ string(SizeStatic),
+ emojiID,
+ // All static emojis
+ // are encoded as png.
+ mimePng,
+ )
+
+ imageStaticPath := uris.StoragePathForAttachment(
+ instanceAcc.ID,
+ string(TypeEmoji),
+ string(SizeStatic),
+ emojiID,
+ // All static emojis
+ // are encoded as png.
+ mimePng,
+ )
+
+ // Populate initial fields on the new emoji,
+ // leaving out fields with values we don't know
+ // yet. These will be overwritten as we go.
emoji = >smodel.Emoji{
ID: emojiID,
CreatedAt: now,
+ UpdatedAt: now,
Shortcode: shortcode,
- Domain: "", // assume our own domain unless told otherwise
- ImageRemoteURL: "",
- ImageStaticRemoteURL: "",
- ImageURL: "", // we don't know yet
- ImageStaticURL: uris.GenerateURIForAttachment(instanceAcc.ID, string(TypeEmoji), string(SizeStatic), emojiID, mimePng), // all static emojis are encoded as png
- ImagePath: "", // we don't know yet
- ImageStaticPath: fmt.Sprintf("%s/%s/%s/%s.%s", instanceAcc.ID, TypeEmoji, SizeStatic, emojiID, mimePng), // all static emojis are encoded as png
- ImageContentType: "", // we don't know yet
- ImageStaticContentType: mimeImagePng, // all static emojis are encoded as png
- ImageFileSize: 0,
- ImageStaticFileSize: 0,
- Disabled: &disabled,
+ ImageStaticURL: imageStaticURL,
+ ImageStaticPath: imageStaticPath,
+ ImageStaticContentType: mimeImagePng,
+ ImageUpdatedAt: now,
+ Disabled: util.Ptr(false),
URI: uri,
- VisibleInPicker: &visibleInPicker,
- CategoryID: "",
+ VisibleInPicker: util.Ptr(true),
}
}
- emoji.ImageUpdatedAt = now
- emoji.UpdatedAt = now
-
- // check if we have additional info to add to the emoji,
- // and overwrite some of the emoji fields if so
+ // Check if we have additional info to add to the emoji,
+ // and overwrite some of the emoji fields if so.
if ai != nil {
if ai.CreatedAt != nil {
emoji.CreatedAt = *ai.CreatedAt
@@ -335,11 +389,17 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode
return processingEmoji, nil
}
-// PreProcessEmojiRecache refetches, reprocesses, and recaches an existing emoji that has been uncached via cleaner pruning.
+// PreProcessEmojiRecache refetches, reprocesses, and recaches
+// an existing emoji that has been uncached via cleaner pruning.
//
-// Note: unlike ProcessEmoji, this will NOT queue the emoji to be asychronously processed.
-func (m *Manager) PreProcessEmojiRecache(ctx context.Context, data DataFunc, emojiID string) (*ProcessingEmoji, error) {
- // get the existing emoji from the database.
+// Note: unlike ProcessEmoji, this will NOT queue the emoji to
+// be asychronously processed.
+func (m *Manager) PreProcessEmojiRecache(
+ ctx context.Context,
+ data DataFunc,
+ emojiID string,
+) (*ProcessingEmoji, error) {
+ // Get the existing emoji from the database.
emoji, err := m.state.DB.GetEmojiByID(ctx, emojiID)
if err != nil {
return nil, err
@@ -348,15 +408,24 @@ func (m *Manager) PreProcessEmojiRecache(ctx context.Context, data DataFunc, emo
processingEmoji := &ProcessingEmoji{
emoji: emoji,
dataFn: data,
- existing: true, // inidcate recache
+ existing: true, // Indicate recache.
mgr: m,
}
return processingEmoji, nil
}
-// ProcessEmoji will call PreProcessEmoji, followed by queuing the emoji to be processing in the emoji worker queue.
-func (m *Manager) ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) {
+// ProcessEmoji will call PreProcessEmoji, followed
+// by queuing the emoji in the emoji worker queue.
+func (m *Manager) ProcessEmoji(
+ ctx context.Context,
+ data DataFunc,
+ shortcode string,
+ id string,
+ uri string,
+ ai *AdditionalEmojiInfo,
+ refresh bool,
+) (*ProcessingEmoji, error) {
// Create a new processing emoji object for this emoji request.
emoji, err := m.PreProcessEmoji(ctx, data, shortcode, id, uri, ai, refresh)
if err != nil {
diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go
index cd0f9c24b..4a3d3c886 100644
--- a/internal/media/manager_test.go
+++ b/internal/media/manager_test.go
@@ -33,7 +33,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/state"
gtsstorage "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
)
type ManagerTestSuite struct {
@@ -319,8 +318,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -376,6 +374,131 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() {
suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
}
+func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() {
+ ctx := context.Background()
+
+ data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ // load bytes from a test image
+ b, err := os.ReadFile("./test/test-jpeg.jpg")
+ if err != nil {
+ panic(err)
+ }
+
+ // Fuck up the bytes a bit by cutting
+ // off the second half, tee hee!
+ b = b[:len(b)/2]
+
+ return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ // process the media with no additional info provided
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
+
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // do a blocking call to fetch the attachment
+ attachment, err := processingMedia.LoadAttachment(ctx)
+
+ // Since we're cutting off the byte stream
+ // halfway through, we should get an error here.
+ suite.EqualError(err, "finish: error decoding image: unexpected EOF")
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.Zero(attachment.FileMeta)
+ suite.Equal("image/jpeg", attachment.File.ContentType)
+ suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
+ suite.Empty(attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // Attachment should have type unknown
+ suite.Equal(gtsmodel.FileTypeUnknown, dbAttachment.Type)
+
+ // Nothing should be in storage for this attachment.
+ stored, err := suite.storage.Has(ctx, attachment.File.Path)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(stored)
+
+ stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(stored)
+}
+
+func (suite *ManagerTestSuite) TestPDFProcess() {
+ ctx := context.Background()
+
+ data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ // load bytes from Frantz
+ b, err := os.ReadFile("./test/Frantz-Fanon-The-Wretched-of-the-Earth-1965.pdf")
+ if err != nil {
+ panic(err)
+ }
+
+ return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
+ }
+
+ accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
+
+ // process the media with no additional info provided
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
+
+ // fetch the attachment id from the processing media
+ attachmentID := processingMedia.AttachmentID()
+
+ // do a blocking call to fetch the attachment
+ attachment, err := processingMedia.LoadAttachment(ctx)
+ suite.NoError(err)
+ suite.NotNil(attachment)
+
+ // make sure it's got the stuff set on it that we expect
+ // the attachment ID and accountID we expect
+ suite.Equal(attachmentID, attachment.ID)
+ suite.Equal(accountID, attachment.AccountID)
+
+ // file meta should be correctly derived from the image
+ suite.Zero(attachment.FileMeta)
+ suite.Equal("application/pdf", attachment.File.ContentType)
+ suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
+ suite.Empty(attachment.Blurhash)
+
+ // now make sure the attachment is in the database
+ dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ suite.NoError(err)
+ suite.NotNil(dbAttachment)
+
+ // Attachment should have type unknown
+ suite.Equal(gtsmodel.FileTypeUnknown, dbAttachment.Type)
+
+ // Nothing should be in storage for this attachment.
+ stored, err := suite.storage.Has(ctx, attachment.File.Path)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(stored)
+
+ stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(stored)
+}
+
func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() {
ctx := context.Background()
@@ -391,8 +514,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -467,8 +589,7 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -543,8 +664,7 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -621,13 +741,16 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// pre processing should go fine but...
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// we should get an error while loading
attachment, err := processingMedia.LoadAttachment(ctx)
suite.EqualError(err, "finish: error decoding video: error determining video metadata: [width height framerate]")
- suite.Nil(attachment)
+
+ // partial attachment should be
+ // returned, with 'unknown' type.
+ suite.NotNil(attachment)
+ suite.Equal(gtsmodel.FileTypeUnknown, attachment.Type)
}
func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven() {
@@ -646,8 +769,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -719,8 +841,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -791,8 +912,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -863,8 +983,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -935,8 +1054,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -992,166 +1110,6 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() {
suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
}
-func (suite *ManagerTestSuite) TestSimpleJpegProcessAsync() {
- 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
- b, err := os.ReadFile("./test/test-jpeg.jpg")
- if err != nil {
- panic(err)
- }
- return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil
- }
-
- accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
-
- // process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
-
- // fetch the attachment id from the processing media
- attachmentID := processingMedia.AttachmentID()
-
- // wait for processing to complete
- var attachment *gtsmodel.MediaAttachment
- if !testrig.WaitFor(func() bool {
- attachment, err = suite.db.GetAttachmentByID(ctx, attachmentID)
- return err == nil && attachment != nil
- }) {
- suite.FailNow("timed out waiting for attachment to process")
- }
-
- // make sure it's got the stuff set on it that we expect
- // the attachment ID and accountID we expect
- suite.Equal(attachmentID, attachment.ID)
- suite.Equal(accountID, attachment.AccountID)
-
- // file meta should be correctly derived from the image
- suite.EqualValues(gtsmodel.Original{
- Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
- }, attachment.FileMeta.Original)
- suite.EqualValues(gtsmodel.Small{
- Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
- }, attachment.FileMeta.Small)
- suite.Equal("image/jpeg", attachment.File.ContentType)
- suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
-
- // now make sure the attachment is in the database
- dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
- suite.NoError(err)
- suite.NotNil(dbAttachment)
-
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, attachment.File.Path)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedFullBytesExpected)
-
- // the bytes in storage should be what we expected
- suite.Equal(processedFullBytesExpected, processedFullBytes)
-
- // now do the same for the thumbnail and make sure it's what we expected
- processedThumbnailBytes, err := suite.storage.Get(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
-}
-
-func (suite *ManagerTestSuite) TestSimpleJpegQueueSpamming() {
- // in this test, we spam the manager queue with 50 new media requests, just to see how it holds up
- ctx := context.Background()
-
- b, err := os.ReadFile("./test/test-jpeg.jpg")
- if err != nil {
- panic(err)
- }
-
- data := func(_ context.Context) (io.ReadCloser, int64, error) {
- // load bytes from a test image
- return io.NopCloser(bytes.NewReader(b)), int64(len(b)), nil
- }
-
- accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
-
- spam := 50
- inProcess := []*media.ProcessingMedia{}
- for i := 0; i < spam; i++ {
- // process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
- inProcess = append(inProcess, processingMedia)
- }
-
- for _, processingMedia := range inProcess {
- // fetch the attachment id from the processing media
- attachmentID := processingMedia.AttachmentID()
-
- // do a blocking call to fetch the attachment
- attachment, err := processingMedia.LoadAttachment(ctx)
- suite.NoError(err)
- suite.NotNil(attachment)
-
- // make sure it's got the stuff set on it that we expect
- // the attachment ID and accountID we expect
- suite.Equal(attachmentID, attachment.ID)
- suite.Equal(accountID, attachment.AccountID)
-
- // file meta should be correctly derived from the image
- suite.EqualValues(gtsmodel.Original{
- Width: 1920, Height: 1080, Size: 2073600, Aspect: 1.7777777777777777,
- }, attachment.FileMeta.Original)
- suite.EqualValues(gtsmodel.Small{
- Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777,
- }, attachment.FileMeta.Small)
- suite.Equal("image/jpeg", attachment.File.ContentType)
- suite.Equal("image/jpeg", attachment.Thumbnail.ContentType)
- suite.Equal(269739, attachment.File.FileSize)
- suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash)
-
- // now make sure the attachment is in the database
- dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
- suite.NoError(err)
- suite.NotNil(dbAttachment)
-
- // make sure the processed file is in storage
- processedFullBytes, err := suite.storage.Get(ctx, attachment.File.Path)
- suite.NoError(err)
- suite.NotEmpty(processedFullBytes)
-
- // load the processed bytes from our test folder, to compare
- processedFullBytesExpected, err := os.ReadFile("./test/test-jpeg-processed.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedFullBytesExpected)
-
- // the bytes in storage should be what we expected
- suite.Equal(processedFullBytesExpected, processedFullBytes)
-
- // now do the same for the thumbnail and make sure it's what we expected
- processedThumbnailBytes, err := suite.storage.Get(ctx, attachment.Thumbnail.Path)
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytes)
-
- processedThumbnailBytesExpected, err := os.ReadFile("./test/test-jpeg-thumbnail.jpg")
- suite.NoError(err)
- suite.NotEmpty(processedThumbnailBytesExpected)
-
- suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes)
- }
-}
-
func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() {
ctx := context.Background()
@@ -1191,8 +1149,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() {
suite.manager = diskManager
// process the media with no additional info provided
- processingMedia, err := diskManager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := diskManager.PreProcessMedia(data, accountID, nil)
// fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
@@ -1290,19 +1247,17 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() {
accountID := "01FS1X72SK9ZPW0J1QQ68BD264"
// process the media with no additional info provided
- processingMedia, err := suite.manager.ProcessMedia(ctx, data, accountID, nil)
- suite.NoError(err)
+ processingMedia := suite.manager.PreProcessMedia(data, accountID, nil)
+ if _, err := processingMedia.LoadAttachment(ctx); err != nil {
+ suite.FailNow(err.Error())
+ }
- // fetch the attachment id from the processing media
attachmentID := processingMedia.AttachmentID()
- // wait for processing to complete
- var attachment *gtsmodel.MediaAttachment
- if !testrig.WaitFor(func() bool {
- attachment, err = suite.db.GetAttachmentByID(ctx, attachmentID)
- return err == nil && attachment != nil
- }) {
- suite.FailNow("timed out waiting for attachment to process")
+ // fetch the attachment id from the processing media
+ attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID)
+ if err != nil {
+ suite.FailNow(err.Error())
}
// make sure it's got the stuff set on it that we expect
@@ -1318,6 +1273,62 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() {
}
}
+func (suite *ManagerTestSuite) TestMisreportedSmallMedia() {
+ const accountID = "01FS1X72SK9ZPW0J1QQ68BD264"
+ var actualSize int
+
+ data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ // Load bytes from small png.
+ b, err := os.ReadFile("./test/test-png-alphachannel-1x1px.png")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ actualSize = len(b)
+
+ // Report media as twice its actual size. This should be corrected.
+ return io.NopCloser(bytes.NewBuffer(b)), int64(2 * actualSize), nil
+ }
+
+ // Process the media with no additional info provided.
+ attachment, err := suite.manager.
+ PreProcessMedia(data, accountID, nil).
+ LoadAttachment(context.Background())
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(actualSize, attachment.File.FileSize)
+}
+
+func (suite *ManagerTestSuite) TestNoReportedSizeSmallMedia() {
+ const accountID = "01FS1X72SK9ZPW0J1QQ68BD264"
+ var actualSize int
+
+ data := func(_ context.Context) (io.ReadCloser, int64, error) {
+ // Load bytes from small png.
+ b, err := os.ReadFile("./test/test-png-alphachannel-1x1px.png")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ actualSize = len(b)
+
+ // Return zero for media size. This should be detected.
+ return io.NopCloser(bytes.NewBuffer(b)), 0, nil
+ }
+
+ // Process the media with no additional info provided.
+ attachment, err := suite.manager.
+ PreProcessMedia(data, accountID, nil).
+ LoadAttachment(context.Background())
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(actualSize, attachment.File.FileSize)
+}
+
func TestManagerTestSuite(t *testing.T) {
suite.Run(t, &ManagerTestSuite{})
}
diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go
index 1c7e60144..4c18d4aad 100644
--- a/internal/media/processingemoji.go
+++ b/internal/media/processingemoji.go
@@ -20,7 +20,6 @@ package media
import (
"bytes"
"context"
- "fmt"
"io"
"codeberg.org/gruf/go-bytesize"
@@ -33,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/regexes"
"github.com/superseriousbusiness/gotosocial/internal/uris"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// ProcessingEmoji represents an emoji currently processing. It exposes
@@ -156,14 +156,51 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
}
}()
- // 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)
+ var maxSize bytesize.Size
- // Read the first 261 header bytes into buffer.
- if _, err := io.ReadFull(rc, hdrBuf); err != nil {
- return gtserror.Newf("error reading incoming media: %w", err)
+ 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 gtserror.Newf("given emoji size %s greater than max allowed %s", size, maxSize)
+ }
+
+ // Prepare to read bytes from
+ // file header or magic number.
+ fileSize := int(sz)
+ hdrBuf := newHdrBuf(fileSize)
+
+ // Read into buffer as much as possible.
+ //
+ // UnexpectedEOF means we couldn't read up to the
+ // given size, but we may still have read something.
+ //
+ // EOF means we couldn't read anything at all.
+ //
+ // Any other error likely means the connection messed up.
+ //
+ // In other words, rather counterintuitively, we
+ // can only proceed on no error or unexpected error!
+ n, err := io.ReadFull(rc, hdrBuf)
+ if err != nil {
+ if err != io.ErrUnexpectedEOF {
+ return gtserror.Newf("error reading first bytes of incoming media: %w", err)
+ }
+
+ // Initial file size was misreported, so we didn't read
+ // fully into hdrBuf. Reslice it to the size we did read.
+ log.Warnf(ctx,
+ "recovered from misreported file size; reported %d; read %d",
+ fileSize, n,
+ )
+ hdrBuf = hdrBuf[:n]
}
// Parse file type info from header buffer.
@@ -184,24 +221,7 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
// 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 gtserror.Newf("given emoji size %s greater than max allowed %s", size, maxSize)
- }
-
var pathID string
-
if p.newPathID != "" {
// This is a refreshed emoji with a new
// path ID that this will be stored under.
@@ -215,11 +235,10 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
instanceAccID := regexes.FilePath.FindStringSubmatch(p.emoji.ImageStaticPath)[1]
// Calculate emoji file path.
- p.emoji.ImagePath = fmt.Sprintf(
- "%s/%s/%s/%s.%s",
+ p.emoji.ImagePath = uris.StoragePathForAttachment(
instanceAccID,
- TypeEmoji,
- SizeOriginal,
+ string(TypeEmoji),
+ string(SizeOriginal),
pathID,
info.Extension,
)
@@ -235,14 +254,13 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
}
// Write the final image reader stream to our storage.
- sz, err = p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r)
+ wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r)
if err != nil {
return gtserror.Newf("error writing emoji to storage: %w", err)
}
// Once again check size in case none was provided previously.
- if size := bytesize.Size(sz); size > maxSize {
-
+ if size := bytesize.Size(wroteSize); size > maxSize {
if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil {
log.Errorf(ctx, "error removing too-large-emoji from storage: %v", err)
}
@@ -251,7 +269,7 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
}
// Fill in remaining attachment data now it's stored.
- p.emoji.ImageURL = uris.GenerateURIForAttachment(
+ p.emoji.ImageURL = uris.URIForAttachment(
instanceAccID,
string(TypeEmoji),
string(SizeOriginal),
@@ -259,11 +277,8 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
info.Extension,
)
p.emoji.ImageContentType = info.MIME.Value
- p.emoji.ImageFileSize = int(sz)
- p.emoji.Cached = func() *bool {
- ok := true
- return &ok
- }()
+ p.emoji.ImageFileSize = int(wroteSize)
+ p.emoji.Cached = util.Ptr(true)
return nil
}
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
index 591cbac74..3627d8cd3 100644
--- a/internal/media/processingmedia.go
+++ b/internal/media/processingmedia.go
@@ -20,12 +20,12 @@ package media
import (
"bytes"
"context"
- "fmt"
+ "errors"
"image/jpeg"
"io"
"time"
- "codeberg.org/gruf/go-errors/v2"
+ errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-runners"
"github.com/disintegration/imaging"
"github.com/h2non/filetype"
@@ -33,11 +33,14 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/uris"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
-// ProcessingMedia represents a piece of media that is currently being processed. It exposes
-// various functions for retrieving data from the process.
+// ProcessingMedia represents a piece of media
+// currently being processed. It exposes functions
+// for retrieving data from the process.
type ProcessingMedia struct {
media *gtsmodel.MediaAttachment // processing media attachment details
dataFn DataFunc // load-data function, returns media stream
@@ -48,39 +51,56 @@ type ProcessingMedia struct {
mgr *Manager // mgr instance (access to db / storage)
}
-// AttachmentID returns the ID of the underlying media attachment without blocking processing.
+// AttachmentID returns the ID of the underlying
+// media attachment without blocking processing.
func (p *ProcessingMedia) AttachmentID() string {
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.
+// LoadAttachment blocks until the thumbnail and
+// fullsize content has been processed, and then
+// returns the attachment.
+//
+// If processing could not be completed fully
+// then an error will be returned. The attachment
+// will still be returned in that case, but it will
+// only be partially complete and should be treated
+// as a placeholder.
func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) {
// Attempt to load synchronously.
media, done, err := p.load(ctx)
-
if err == nil {
// No issue, return media.
return media, nil
}
if !done {
- // Provided context was cancelled, e.g. request cancelled
- // early. Queue this item for asynchronous processing.
+ // Provided context was cancelled,
+ // e.g. request aborted early before
+ // its context could be used to finish
+ // loading the attachment. Enqueue for
+ // asynchronous processing, which will
+ // use a background context.
log.Warnf(ctx, "reprocessing media %s after canceled ctx", p.media.ID)
go p.mgr.state.Workers.Media.Enqueue(p.Process)
}
- return nil, err
+ // Media could not be retrieved FULLY,
+ // but partial attachment should be present.
+ return media, err
}
-// Process allows the receiving object to fit the runners.WorkerFunc signature. It performs a (blocking) load and logs on error.
+// Process allows the receiving object to fit the
+// runners.WorkerFunc signature. It performs a
+// (blocking) load and logs on error.
func (p *ProcessingMedia) Process(ctx context.Context) {
if _, _, err := p.load(ctx); err != nil {
- log.Errorf(ctx, "error processing media: %v", err)
+ log.Errorf(ctx, "error(s) processing media: %v", err)
}
}
-// load performs a concurrency-safe load of ProcessingMedia, only marking itself as complete when returned error is NOT a context cancel.
+// load performs a concurrency-safe load of ProcessingMedia, only
+// marking itself as complete when returned error is NOT a context cancel.
func (p *ProcessingMedia) load(ctx context.Context) (*gtsmodel.MediaAttachment, bool, error) {
var (
done bool
@@ -95,7 +115,7 @@ func (p *ProcessingMedia) load(ctx context.Context) (*gtsmodel.MediaAttachment,
defer func() {
// This is only done when ctx NOT cancelled.
- done = err == nil || !errors.Comparable(err,
+ done = err == nil || !errorsv2.Comparable(err,
context.Canceled,
context.DeadlineExceeded,
)
@@ -109,34 +129,61 @@ func (p *ProcessingMedia) load(ctx context.Context) (*gtsmodel.MediaAttachment,
p.err = err
}()
+ // Gather errors as we proceed.
+ var errs = gtserror.NewMultiError(4)
+
// Attempt to store media and calculate
// full-size media attachment details.
- if err = p.store(ctx); err != nil {
- return err
+ //
+ // This will update p.media as it goes.
+ storeErr := p.store(ctx)
+ if storeErr != nil {
+ errs.Append(storeErr)
}
// Finish processing by reloading media into
// memory to get dimension and generate a thumb.
- if err = p.finish(ctx); err != nil {
- return err
+ //
+ // This will update p.media as it goes.
+ if finishErr := p.finish(ctx); finishErr != nil {
+ errs.Append(finishErr)
}
- if p.recache {
- // Existing attachment we're recaching, so only update.
- err = p.mgr.state.DB.UpdateAttachment(ctx, p.media)
- return err
+ // If this isn't a file we were able to process,
+ // we may have partially stored it (eg., it's a
+ // jpeg, which is fine, but streaming it to storage
+ // was interrupted halfway through and so it was
+ // never decoded). Try to clean up in this case.
+ if p.media.Type == gtsmodel.FileTypeUnknown {
+ deleteErr := p.mgr.state.Storage.Delete(ctx, p.media.File.Path)
+ if deleteErr != nil && !errors.Is(deleteErr, storage.ErrNotFound) {
+ errs.Append(deleteErr)
+ }
}
- // First time caching this attachment, insert it.
- err = p.mgr.state.DB.PutAttachment(ctx, p.media)
+ var dbErr error
+ switch {
+ case !p.recache:
+ // First time caching this attachment, insert it.
+ dbErr = p.mgr.state.DB.PutAttachment(ctx, p.media)
+
+ case p.recache && len(errs) == 0:
+ // Existing attachment we're recaching, update it.
+ //
+ // (We only want to update if everything went OK so far,
+ // otherwise we'd better leave previous version alone.)
+ dbErr = p.mgr.state.DB.UpdateAttachment(ctx, p.media)
+ }
+
+ if dbErr != nil {
+ errs.Append(dbErr)
+ }
+
+ err = errs.Combine()
return err
})
- if err != nil {
- return nil, done, err
- }
-
- return p.media, done, nil
+ return p.media, done, err
}
// store calls the data function attached to p if it hasn't been called yet,
@@ -156,17 +203,47 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
}
}()
- // 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)
+ // Assume we're given correct file
+ // size, we can overwrite this later
+ // once we know THE TRUTH.
+ fileSize := int(sz)
+ p.media.File.FileSize = fileSize
- // Read the first 261 header bytes into buffer as much as possible.
- if _, err := rc.Read(hdrBuf); err != nil {
- return gtserror.Newf("error reading incoming media: %w", err)
+ // Prepare to read bytes from
+ // file header or magic number.
+ hdrBuf := newHdrBuf(fileSize)
+
+ // Read into buffer as much as possible.
+ //
+ // UnexpectedEOF means we couldn't read up to the
+ // given size, but we may still have read something.
+ //
+ // EOF means we couldn't read anything at all.
+ //
+ // Any other error likely means the connection messed up.
+ //
+ // In other words, rather counterintuitively, we
+ // can only proceed on no error or unexpected error!
+ n, err := io.ReadFull(rc, hdrBuf)
+ if err != nil {
+ if err != io.ErrUnexpectedEOF {
+ return gtserror.Newf("error reading first bytes of incoming media: %w", err)
+ }
+
+ // Initial file size was misreported, so we didn't read
+ // fully into hdrBuf. Reslice it to the size we did read.
+ log.Warnf(ctx,
+ "recovered from misreported file size; reported %d; read %d",
+ fileSize, n,
+ )
+ hdrBuf = hdrBuf[:n]
+ fileSize = n
+ p.media.File.FileSize = fileSize
}
// Parse file type info from header buffer.
+ // This should only ever error if the buffer
+ // is empty (ie., the attachment is 0 bytes).
info, err := filetype.Match(hdrBuf)
if err != nil {
return gtserror.Newf("error parsing file type: %w", err)
@@ -175,38 +252,77 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
// Recombine header bytes with remaining stream
r := io.MultiReader(bytes.NewReader(hdrBuf), rc)
+ // Assume we'll put
+ // this file in storage.
+ store := true
+
switch info.Extension {
case "mp4":
- p.media.Type = gtsmodel.FileTypeVideo
+ // No problem.
case "gif":
- p.media.Type = gtsmodel.FileTypeImage
+ // No problem
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 fileSize > 0 {
+ // A file size was provided so we can clean
+ // exif data from image as we're streaming it.
+ r, err = terminator.Terminate(r, fileSize, info.Extension)
if err != nil {
return gtserror.Newf("error cleaning exif data: %w", err)
}
}
default:
- return gtserror.Newf("unsupported file type: %s", info.Extension)
+ // The file is not a supported format that
+ // we can process, so we can't do much with it.
+ log.Warnf(ctx,
+ "media extension '%s' not officially supported, will be processed as "+
+ "type '%s' with minimal metadata, and will not be cached locally",
+ info.Extension, gtsmodel.FileTypeUnknown,
+ )
+
+ // Don't bother storing this.
+ store = false
}
- // Calculate attachment file path.
- p.media.File.Path = fmt.Sprintf(
- "%s/%s/%s/%s.%s",
+ // Fill in correct attachment
+ // data now we're parsed it.
+ p.media.URL = uris.URIForAttachment(
p.media.AccountID,
- TypeAttachment,
- SizeOriginal,
+ string(TypeAttachment),
+ string(SizeOriginal),
p.media.ID,
info.Extension,
)
- // This shouldn't already exist, but we do a check as it's worth logging.
+ // Prefer discovered mime type, fall back to
+ // generic "this contains some bytes" type.
+ mime := info.MIME.Value
+ if mime == "" {
+ mime = "application/octet-stream"
+ }
+ p.media.File.ContentType = mime
+
+ // Calculate attachment file path.
+ p.media.File.Path = uris.StoragePathForAttachment(
+ p.media.AccountID,
+ string(TypeAttachment),
+ string(SizeOriginal),
+ p.media.ID,
+ info.Extension,
+ )
+
+ // We should only try to store the file if it's
+ // a format we can keep processing, otherwise be
+ // a bit cheeky: don't store it and let users
+ // click through to the remote server instead.
+ if !store {
+ return nil
+ }
+
+ // File shouldn't already exist in storage at this point,
+ // but we do a check as it's worth logging / cleaning up.
if have, _ := p.mgr.state.Storage.Has(ctx, p.media.File.Path); have {
log.Warnf(ctx, "media already exists at storage path: %s", p.media.File.Path)
@@ -216,59 +332,99 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
}
}
- // Write the final image reader stream to our storage.
- sz, err = p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r)
+ // Write the final reader stream to our storage.
+ wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r)
if err != nil {
return gtserror.Newf("error writing media to storage: %w", err)
}
- // Set written image size.
- p.media.File.FileSize = int(sz)
+ // Set actual written size
+ // as authoritative file size.
+ p.media.File.FileSize = int(wroteSize)
- // 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
- p.media.Cached = func() *bool {
- ok := true
- return &ok
- }()
+ // We can now consider this cached.
+ p.media.Cached = util.Ptr(true)
return nil
}
func (p *ProcessingMedia) finish(ctx context.Context) error {
- // Fetch a stream to the original file in storage.
+ // Make a jolly assumption about thumbnail type.
+ p.media.Thumbnail.ContentType = mimeImageJpeg
+
+ // Calculate attachment thumbnail file path
+ p.media.Thumbnail.Path = uris.StoragePathForAttachment(
+ p.media.AccountID,
+ string(TypeAttachment),
+ string(SizeSmall),
+ p.media.ID,
+ // Always encode attachment
+ // thumbnails as jpg.
+ "jpg",
+ )
+
+ // Calculate attachment thumbnail serve path.
+ p.media.Thumbnail.URL = uris.URIForAttachment(
+ p.media.AccountID,
+ string(TypeAttachment),
+ string(SizeSmall),
+ p.media.ID,
+ // Always encode attachment
+ // thumbnails as jpg.
+ "jpg",
+ )
+
+ // If original file hasn't been stored, there's
+ // likely something wrong with the data, or we
+ // don't want to store it. Skip everything else.
+ if !*p.media.Cached {
+ p.media.Processing = gtsmodel.ProcessingStatusProcessed
+ return nil
+ }
+
+ // Get a stream to the original file for further processing.
rc, err := p.mgr.state.Storage.GetStream(ctx, p.media.File.Path)
if err != nil {
return gtserror.Newf("error loading file from storage: %w", err)
}
defer rc.Close()
+ // fullImg is the processed version of
+ // the original (stripped + reoriented).
var fullImg *gtsImage
+ // Depending on the content type, we
+ // can do various types of decoding.
switch p.media.File.ContentType {
+
// .jpeg, .gif, .webp image type
case mimeImageJpeg, mimeImageGif, mimeImageWebp:
- fullImg, err = decodeImage(rc, imaging.AutoOrientation(true))
+ fullImg, err = decodeImage(
+ rc,
+ imaging.AutoOrientation(true),
+ )
if err != nil {
return gtserror.Newf("error decoding image: %w", err)
}
+ // Mark as no longer unknown type now
+ // we know for sure we can decode it.
+ p.media.Type = gtsmodel.FileTypeImage
+
// .png image (requires ancillary chunk stripping)
case mimeImagePng:
- fullImg, err = decodeImage(&pngAncillaryChunkStripper{
- Reader: rc,
- }, imaging.AutoOrientation(true))
+ fullImg, err = decodeImage(
+ &pngAncillaryChunkStripper{Reader: rc},
+ imaging.AutoOrientation(true),
+ )
if err != nil {
return gtserror.Newf("error decoding image: %w", err)
}
+ // Mark as no longer unknown type now
+ // we know for sure we can decode it.
+ p.media.Type = gtsmodel.FileTypeImage
+
// .mp4 video type
case mimeVideoMp4:
video, err := decodeVideoFrame(rc)
@@ -283,9 +439,14 @@ func (p *ProcessingMedia) finish(ctx context.Context) error {
p.media.FileMeta.Original.Duration = &video.duration
p.media.FileMeta.Original.Framerate = &video.framerate
p.media.FileMeta.Original.Bitrate = &video.bitrate
+
+ // Mark as no longer unknown type now
+ // we know for sure we can decode it.
+ p.media.Type = gtsmodel.FileTypeVideo
}
- // The image should be in-memory by now.
+ // fullImg should be in-memory by
+ // now so we're done with storage.
if err := rc.Close(); err != nil {
return gtserror.Newf("error closing file: %w", err)
}
@@ -296,15 +457,6 @@ func (p *ProcessingMedia) finish(ctx context.Context) error {
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()
@@ -312,16 +464,20 @@ func (p *ProcessingMedia) finish(ctx context.Context) error {
// now take our large son.
fullImg = nil
- // Blurhash needs generating from thumb.
- hash, err := thumbImg.Blurhash()
- if err != nil {
- return gtserror.Newf("error generating blurhash: %w", err)
+ // Only generate blurhash
+ // from thumb if necessary.
+ if p.media.Blurhash == "" {
+ hash, err := thumbImg.Blurhash()
+ if err != nil {
+ return gtserror.Newf("error generating blurhash: %w", err)
+ }
+
+ // Set the attachment blurhash.
+ p.media.Blurhash = hash
}
- // Set the attachment blurhash.
- p.media.Blurhash = hash
-
- // This shouldn't already exist, but we do a check as it's worth logging.
+ // Thumbnail shouldn't already exist in storage at this point,
+ // but we do a check as it's worth logging / cleaning up.
if have, _ := p.mgr.state.Storage.Has(ctx, p.media.Thumbnail.Path); have {
log.Warnf(ctx, "thumbnail already exists at storage path: %s", p.media.Thumbnail.Path)
@@ -333,7 +489,9 @@ func (p *ProcessingMedia) finish(ctx context.Context) error {
// Create a thumbnail JPEG encoder stream.
enc := thumbImg.ToJPEG(&jpeg.Options{
- Quality: 70, // enough for a thumbnail.
+ // Good enough for
+ // a thumbnail.
+ Quality: 70,
})
// Stream-encode the JPEG thumbnail image into storage.
@@ -342,16 +500,6 @@ func (p *ProcessingMedia) finish(ctx context.Context) error {
return gtserror.Newf("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()),
diff --git a/internal/media/test/Frantz-Fanon-The-Wretched-of-the-Earth-1965.pdf b/internal/media/test/Frantz-Fanon-The-Wretched-of-the-Earth-1965.pdf
new file mode 100644
index 000000000..b2e4de4a5
Binary files /dev/null and b/internal/media/test/Frantz-Fanon-The-Wretched-of-the-Earth-1965.pdf differ
diff --git a/internal/media/types.go b/internal/media/types.go
index 35c62a947..6e7727cd5 100644
--- a/internal/media/types.go
+++ b/internal/media/types.go
@@ -44,9 +44,6 @@ const (
mimeVideoMp4 = mimeVideo + "/" + mimeMp4
)
-// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
-// const EmojiMaxBytes = 51200
-
type Size string
const (
diff --git a/internal/media/util.go b/internal/media/util.go
new file mode 100644
index 000000000..1595da6d7
--- /dev/null
+++ b/internal/media/util.go
@@ -0,0 +1,42 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package media
+
+// newHdrBuf returns a buffer of suitable size to
+// read bytes from a file header or magic number.
+//
+// File header is *USUALLY* 261 bytes at the start
+// of a file; magic number can be much less than
+// that (just a few bytes).
+//
+// To cover both cases, this function returns a buffer
+// suitable for whichever is smallest: the first 261
+// bytes of the file, or the whole file.
+//
+// See:
+//
+// - https://en.wikipedia.org/wiki/File_format#File_header
+// - https://github.com/h2non/filetype.
+func newHdrBuf(fileSize int) []byte {
+ bufSize := 261
+ if fileSize > 0 && fileSize < bufSize {
+ bufSize = fileSize
+ }
+
+ return make([]byte, bufSize)
+}
diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go
index 81485e165..5dc93fa1d 100644
--- a/internal/processing/account/update.go
+++ b/internal/processing/account/update.go
@@ -33,6 +33,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
@@ -281,57 +282,76 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
return acctSensitive, nil
}
-// UpdateAvatar does the dirty work of checking the avatar part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new avatar image.
-func (p *Processor) UpdateAvatar(ctx context.Context, avatar *multipart.FileHeader, description *string, accountID string) (*gtsmodel.MediaAttachment, error) {
+// UpdateAvatar does the dirty work of checking the avatar
+// part of an account update form, parsing and checking the
+// media, and doing the necessary updates in the database
+// for this to become the account's new avatar.
+func (p *Processor) UpdateAvatar(
+ ctx context.Context,
+ avatar *multipart.FileHeader,
+ description *string,
+ accountID string,
+) (*gtsmodel.MediaAttachment, error) {
maxImageSize := config.GetMediaImageMaxSize()
if avatar.Size > int64(maxImageSize) {
- return nil, fmt.Errorf("UpdateAvatar: avatar with size %d exceeded max image size of %d bytes", avatar.Size, maxImageSize)
+ return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", avatar.Size, maxImageSize)
}
- dataFunc := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
+ data := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
f, err := avatar.Open()
return f, avatar.Size, err
}
- isAvatar := true
- ai := &media.AdditionalMediaInfo{
- Avatar: &isAvatar,
+ // Process the media attachment and load it immediately.
+ media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{
+ Avatar: util.Ptr(true),
Description: description,
- }
+ })
- processingMedia, err := p.mediaManager.PreProcessMedia(ctx, dataFunc, accountID, ai)
+ attachment, err := media.LoadAttachment(ctx)
if err != nil {
- return nil, fmt.Errorf("UpdateAvatar: error processing avatar: %s", err)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ } else if attachment.Type == gtsmodel.FileTypeUnknown {
+ err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
- return processingMedia.LoadAttachment(ctx)
+ return attachment, nil
}
-// UpdateHeader does the dirty work of checking the header part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new header image.
-func (p *Processor) UpdateHeader(ctx context.Context, header *multipart.FileHeader, description *string, accountID string) (*gtsmodel.MediaAttachment, error) {
+// UpdateHeader does the dirty work of checking the header
+// part of an account update form, parsing and checking the
+// media, and doing the necessary updates in the database
+// for this to become the account's new header.
+func (p *Processor) UpdateHeader(
+ ctx context.Context,
+ header *multipart.FileHeader,
+ description *string,
+ accountID string,
+) (*gtsmodel.MediaAttachment, error) {
maxImageSize := config.GetMediaImageMaxSize()
if header.Size > int64(maxImageSize) {
- return nil, fmt.Errorf("UpdateHeader: header with size %d exceeded max image size of %d bytes", header.Size, maxImageSize)
+ return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", header.Size, maxImageSize)
}
- dataFunc := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
+ data := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
f, err := header.Open()
return f, header.Size, err
}
- isHeader := true
- ai := &media.AdditionalMediaInfo{
- Header: &isHeader,
- }
+ // Process the media attachment and load it immediately.
+ media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{
+ Header: util.Ptr(true),
+ Description: description,
+ })
- processingMedia, err := p.mediaManager.PreProcessMedia(ctx, dataFunc, accountID, ai)
+ attachment, err := media.LoadAttachment(ctx)
if err != nil {
- return nil, fmt.Errorf("UpdateHeader: error processing header: %s", err)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ } else if attachment.Type == gtsmodel.FileTypeUnknown {
+ err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
- return processingMedia.LoadAttachment(ctx)
+ return attachment, nil
}
diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go
index 568c4350b..689aad9dc 100644
--- a/internal/processing/admin/emoji.go
+++ b/internal/processing/admin/emoji.go
@@ -55,7 +55,7 @@ func (p *Processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account,
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating id for new emoji: %s", err), "error creating emoji ID")
}
- emojiURI := uris.GenerateURIForEmoji(emojiID)
+ emojiURI := uris.URIForEmoji(emojiID)
data := func(innerCtx context.Context) (io.ReadCloser, int64, error) {
f, err := form.Image.Open()
@@ -335,7 +335,7 @@ func (p *Processor) emojiUpdateCopy(ctx context.Context, emoji *gtsmodel.Emoji,
return nil, gtserror.NewErrorInternalError(err)
}
- newEmojiURI := uris.GenerateURIForEmoji(newEmojiID)
+ newEmojiURI := uris.URIForEmoji(newEmojiID)
data := func(ctx context.Context) (reader io.ReadCloser, fileSize int64, err error) {
rc, err := p.state.Storage.GetStream(ctx, emoji.ImagePath)
diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go
index b8c469dde..fe20457b4 100644
--- a/internal/processing/media/create.go
+++ b/internal/processing/media/create.go
@@ -42,18 +42,18 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
}
// process the media attachment and load it immediately
- media, err := p.mediaManager.PreProcessMedia(ctx, data, account.ID, &media.AdditionalMediaInfo{
+ media := p.mediaManager.PreProcessMedia(data, account.ID, &media.AdditionalMediaInfo{
Description: &form.Description,
FocusX: &focusX,
FocusY: &focusY,
})
- if err != nil {
- return nil, gtserror.NewErrorUnprocessableEntity(err)
- }
attachment, err := media.LoadAttachment(ctx)
if err != nil {
- return nil, gtserror.NewErrorUnprocessableEntity(err)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ } else if attachment.Type == gtsmodel.FileTypeUnknown {
+ err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType)
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
apiAttachment, err := p.converter.AttachmentToAPIAttachment(ctx, attachment)
diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go
index 386c3a9a2..28f5e6464 100644
--- a/internal/processing/media/getfile.go
+++ b/internal/processing/media/getfile.go
@@ -23,17 +23,24 @@ import (
"io"
"net/url"
"strings"
+ "time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
-// GetFile retrieves a file from storage and streams it back to the caller via an io.reader embedded in *apimodel.Content.
-func (p *Processor) GetFile(ctx context.Context, requestingAccount *gtsmodel.Account, form *apimodel.GetContentRequestForm) (*apimodel.Content, gtserror.WithCode) {
+// GetFile retrieves a file from storage and streams it back
+// to the caller via an io.reader embedded in *apimodel.Content.
+func (p *Processor) GetFile(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ form *apimodel.GetContentRequestForm,
+) (*apimodel.Content, gtserror.WithCode) {
// parse the form fields
mediaSize, err := parseSize(form.MediaSize)
if err != nil {
@@ -118,11 +125,35 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount
// retrieve attachment from the database and do basic checks on it
a, err := p.state.DB.GetAttachmentByID(ctx, wantedMediaID)
if err != nil {
- return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %w", wantedMediaID, err))
+ err = gtserror.Newf("attachment %s could not be taken from the db: %w", wantedMediaID, err)
+ return nil, gtserror.NewErrorNotFound(err)
}
if a.AccountID != owningAccountID {
- return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, owningAccountID))
+ err = gtserror.Newf("attachment %s is not owned by %s", wantedMediaID, owningAccountID)
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+
+ // If this is an "Unknown" file type, ie., one we
+ // tried to process and couldn't, or one we refused
+ // to process because it wasn't supported, then we
+ // can skip a lot of steps here by simply forwarding
+ // the request to the remote URL.
+ if a.Type == gtsmodel.FileTypeUnknown {
+ remoteURL, err := url.Parse(a.RemoteURL)
+ if err != nil {
+ err = gtserror.Newf("error parsing remote URL of 'Unknown'-type attachment for redirection: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ url := &storage.PresignedURL{
+ URL: remoteURL,
+ // We might manage to cache the media
+ // at some point, so set a low-ish expiry.
+ Expiry: time.Now().Add(2 * time.Hour),
+ }
+
+ return &apimodel.Content{URL: url}, nil
}
if !*a.Cached {
@@ -205,7 +236,7 @@ func (p *Processor) getEmojiContent(ctx context.Context, fileName string, owning
// for using the static URL rather than full size url
// is that static emojis are always encoded as png,
// so this is more reliable than using full size url
- imageStaticURL := uris.GenerateURIForAttachment(
+ imageStaticURL := uris.URIForAttachment(
owningAccountID,
string(media.TypeEmoji),
string(media.SizeStatic),
diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go
index 8c939a61e..170dd0e53 100644
--- a/internal/processing/status/get.go
+++ b/internal/processing/status/get.go
@@ -36,8 +36,12 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
-// ContextGet returns the context (previous and following posts) from the given status ID.
-func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
+func (p *Processor) contextGet(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetStatusID string,
+ convert func(context.Context, *gtsmodel.Status, *gtsmodel.Account) (*apimodel.Status, error),
+) (*apimodel.Context, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
@@ -55,7 +59,7 @@ func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.
for _, status := range parents {
if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
+ apiStatus, err := convert(ctx, status, requestingAccount)
if err == nil {
context.Ancestors = append(context.Ancestors, *apiStatus)
}
@@ -73,7 +77,7 @@ func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.
for _, status := range children {
if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
+ apiStatus, err := convert(ctx, status, requestingAccount)
if err == nil {
context.Descendants = append(context.Descendants, *apiStatus)
}
@@ -82,3 +86,16 @@ func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.
return context, nil
}
+
+// ContextGet returns the context (previous and following posts) from the given status ID.
+func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
+ return p.contextGet(ctx, requestingAccount, targetStatusID, p.converter.StatusToAPIStatus)
+}
+
+// WebContextGet is like ContextGet, but is explicitly
+// for viewing statuses via the unauthenticated web UI.
+//
+// TODO: a more advanced threading model could be implemented here.
+func (p *Processor) WebContextGet(ctx context.Context, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
+ return p.contextGet(ctx, nil, targetStatusID, p.converter.StatusToWebStatus)
+}
diff --git a/internal/text/goldmark_custom_renderer.go b/internal/text/goldmark_custom_renderer.go
index 438692577..0b31d900b 100644
--- a/internal/text/goldmark_custom_renderer.go
+++ b/internal/text/goldmark_custom_renderer.go
@@ -319,7 +319,7 @@ func (cr *customRenderer) handleHashtag(text string) string {
// `#SomeHashtag`
var b strings.Builder
b.WriteString(`#`)
b.WriteString(normalized)
b.WriteString(``)
diff --git a/internal/timeline/prune_test.go b/internal/timeline/prune_test.go
index 4a76abfc9..46bd652b7 100644
--- a/internal/timeline/prune_test.go
+++ b/internal/timeline/prune_test.go
@@ -40,7 +40,7 @@ func (suite *PruneTestSuite) TestPrune() {
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
suite.NoError(err)
- suite.Equal(15, pruned)
+ suite.Equal(16, pruned)
suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
}
@@ -56,7 +56,7 @@ func (suite *PruneTestSuite) TestPruneTwice() {
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
suite.NoError(err)
- suite.Equal(15, pruned)
+ suite.Equal(16, pruned)
suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
// Prune same again, nothing should be pruned this time.
@@ -78,7 +78,7 @@ func (suite *PruneTestSuite) TestPruneTo0() {
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
suite.NoError(err)
- suite.Equal(20, pruned)
+ suite.Equal(21, pruned)
suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
}
@@ -95,7 +95,7 @@ func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() {
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
suite.NoError(err)
suite.Equal(0, pruned)
- suite.Equal(20, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
+ suite.Equal(21, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
}
func TestPruneTestSuite(t *testing.T) {
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index a668989e6..541e2f4d1 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -954,7 +954,7 @@ func (c *Converter) TagToAS(ctx context.Context, t *gtsmodel.Tag) (vocab.TootHas
// This is probably already lowercase,
// but let's err on the safe side.
nameLower := strings.ToLower(t.Name)
- tagURLString := uris.GenerateURIForTag(nameLower)
+ tagURLString := uris.URIForTag(nameLower)
// Create the tag.
tag := streams.NewTootHashtag()
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d5a1dee32..a7bcddac6 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -434,11 +434,14 @@ func (c *Converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati
// AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API.
func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
apiAttachment := apimodel.Attachment{
- ID: a.ID,
- Type: strings.ToLower(string(a.Type)),
- TextURL: a.URL,
- PreviewURL: a.Thumbnail.URL,
- Meta: apimodel.MediaMeta{
+ ID: a.ID,
+ Type: strings.ToLower(string(a.Type)),
+ }
+
+ // Don't try to serialize meta for
+ // unknown attachments, there's no point.
+ if a.Type != gtsmodel.FileTypeUnknown {
+ apiAttachment.Meta = &apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: a.FileMeta.Original.Width,
Height: a.FileMeta.Original.Height,
@@ -449,13 +452,20 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height),
Aspect: float32(a.FileMeta.Small.Aspect),
},
- },
- Blurhash: a.Blurhash,
+ }
+ }
+
+ if i := a.Blurhash; i != "" {
+ apiAttachment.Blurhash = &i
}
- // nullable fields
if i := a.URL; i != "" {
apiAttachment.URL = &i
+ apiAttachment.TextURL = &i
+ }
+
+ if i := a.Thumbnail.URL; i != "" {
+ apiAttachment.PreviewURL = &i
}
if i := a.RemoteURL; i != "" {
@@ -470,8 +480,9 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
apiAttachment.Description = &i
}
- // type specific fields
+ // Type-specific fields.
switch a.Type {
+
case gtsmodel.FileTypeImage:
apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height)
apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect)
@@ -479,16 +490,17 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
X: a.FileMeta.Focus.X,
Y: a.FileMeta.Focus.Y,
}
+
case gtsmodel.FileTypeVideo:
if i := a.FileMeta.Original.Duration; i != nil {
apiAttachment.Meta.Original.Duration = *i
}
if i := a.FileMeta.Original.Framerate; i != nil {
- // the masto api expects this as a string in
- // the format `integer/1`, so 30fps is `30/1`
+ // The masto api expects this as a string in
+ // the format `integer/1`, so 30fps is `30/1`.
round := math.Round(float64(*i))
- fr := strconv.FormatInt(int64(round), 10)
+ fr := strconv.Itoa(int(round))
apiAttachment.Meta.Original.FrameRate = fr + "/1"
}
@@ -599,7 +611,7 @@ func (c *Converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, categor
func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error) {
return apimodel.Tag{
Name: strings.ToLower(t.Name),
- URL: uris.GenerateURIForTag(t.Name),
+ URL: uris.URIForTag(t.Name),
History: func() *[]any {
if !stubHistory {
return nil
@@ -611,15 +623,56 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
}, nil
}
-// StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API.
+// StatusToAPIStatus converts a gts model status into its api
+// (frontend) representation for serialization on the API.
//
// Requesting account can be nil.
-func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
+func (c *Converter) StatusToAPIStatus(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
+ apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
+ if err != nil {
+ return nil, err
+ }
+
+ // Normalize status for the API by pruning
+ // out unknown attachment types and replacing
+ // them with a helpful message.
+ var aside string
+ aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments)
+ apiStatus.Content += aside
+
+ return apiStatus, nil
+}
+
+// StatusToWebStatus converts a gts model status into an
+// api representation suitable for serving into a web template.
+//
+// Requesting account can be nil.
+func (c *Converter) StatusToWebStatus(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
+ return c.statusToFrontend(ctx, s, requestingAccount)
+}
+
+// statusToFrontend is a package internal function for
+// parsing a status into its initial frontend representation.
+//
+// Requesting account can be nil.
+func (c *Converter) statusToFrontend(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
if err := c.state.DB.PopulateStatus(ctx, s); err != nil {
// Ensure author account present + correct;
// can't really go further without this!
if s.Account == nil {
- return nil, fmt.Errorf("error(s) populating status, cannot continue: %w", err)
+ return nil, gtserror.Newf("error(s) populating status, cannot continue: %w", err)
}
log.Errorf(ctx, "error(s) populating status, will continue: %v", err)
@@ -627,22 +680,22 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
apiAuthorAccount, err := c.AccountToAPIAccountPublic(ctx, s.Account)
if err != nil {
- return nil, fmt.Errorf("error converting status author: %w", err)
+ return nil, gtserror.Newf("error converting status author: %w", err)
}
repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting replies: %w", err)
+ return nil, gtserror.Newf("error counting replies: %w", err)
}
reblogsCount, err := c.state.DB.CountStatusBoosts(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting reblogs: %w", err)
+ return nil, gtserror.Newf("error counting reblogs: %w", err)
}
favesCount, err := c.state.DB.CountStatusFaves(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting faves: %w", err)
+ return nil, gtserror.Newf("error counting faves: %w", err)
}
interacts, err := c.interactionsWithStatusForAccount(ctx, s, requestingAccount)
@@ -722,7 +775,7 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
if s.BoostOf != nil {
apiBoostOf, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount)
if err != nil {
- return nil, fmt.Errorf("error converting boosted status: %w", err)
+ return nil, gtserror.Newf("error converting boosted status: %w", err)
}
apiStatus.Reblog = &apimodel.StatusReblogged{Status: apiBoostOf}
@@ -733,13 +786,13 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
if app == nil {
app, err = c.state.DB.GetApplicationByID(ctx, appID)
if err != nil {
- return nil, fmt.Errorf("error getting application %s: %w", appID, err)
+ return nil, gtserror.Newf("error getting application %s: %w", appID, err)
}
}
apiApp, err := c.AppToAPIAppPublic(ctx, app)
if err != nil {
- return nil, fmt.Errorf("error converting application %s: %w", appID, err)
+ return nil, gtserror.Newf("error converting application %s: %w", appID, err)
}
apiStatus.Application = apiApp
@@ -757,11 +810,9 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
}
}
- // Normalization.
-
+ // If web URL is empty for whatever
+ // reason, provide AP URI as fallback.
if s.URL == "" {
- // URL was empty for some reason;
- // provide AP URI as fallback.
s.URL = s.URI
}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 0e09faeea..0e5d3a45b 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -344,7 +344,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
"language": "en",
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
- "replies_count": 0,
+ "replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
@@ -437,6 +437,105 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
}`, string(b))
}
+func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() {
+ testStatus := suite.testStatuses["remote_account_2_status_1"]
+ requestingAccount := suite.testAccounts["admin_account"]
+
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
+ suite.NoError(err)
+
+ b, err := json.MarshalIndent(apiStatus, "", " ")
+ suite.NoError(err)
+
+ suite.Equal(`{
+ "id": "01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "created_at": "2023-11-02T10:44:25.000Z",
+ "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R",
+ "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF",
+ "sensitive": false,
+ "spoiler_text": "",
+ "visibility": "public",
+ "language": "en",
+ "uri": "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "url": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "replies_count": 0,
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003caside\u003e\u003cp\u003eNote from localhost:8080: 2 attachments in this status could not be downloaded. Treat the following external links with care:\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e\u003c/p\u003e\u003c/aside\u003e",
+ "reblog": null,
+ "account": {
+ "id": "01FHMQX3GAABWSM0S2VZEC2SWC",
+ "username": "Some_User",
+ "acct": "Some_User@example.org",
+ "display_name": "some user",
+ "locked": true,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2020-08-10T12:13:28.000Z",
+ "note": "i'm a real son of a gun",
+ "url": "http://example.org/@Some_User",
+ "avatar": "",
+ "avatar_static": "",
+ "header": "http://localhost:8080/assets/default_header.png",
+ "header_static": "http://localhost:8080/assets/default_header.png",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 1,
+ "last_status_at": "2023-11-02T10:44:25.000Z",
+ "emojis": [],
+ "fields": []
+ },
+ "media_attachments": [
+ {
+ "id": "01HE7Y3C432WRSNS10EZM86SA5",
+ "type": "image",
+ "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7Y6G0EMCKST3Q0914WW0MS.jpg",
+ "preview_remote_url": null,
+ "meta": {
+ "original": {
+ "width": 3000,
+ "height": 2000,
+ "size": "3000x2000",
+ "aspect": 1.5
+ },
+ "small": {
+ "width": 512,
+ "height": 341,
+ "size": "512x341",
+ "aspect": 1.5014663
+ },
+ "focus": {
+ "x": 0,
+ "y": 0
+ }
+ },
+ "description": "Photograph of a sloth, Public Domain.",
+ "blurhash": "LNEC{|w}0K9GsEtPM|j[NFbHoeof"
+ }
+ ],
+ "mentions": [
+ {
+ "id": "01F8MH17FWEB39HZJ76B6VXSKF",
+ "username": "admin",
+ "url": "http://localhost:8080/@admin",
+ "acct": "admin"
+ }
+ ],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null
+}`, string(b))
+}
+
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() {
testStatus := >smodel.Status{}
*testStatus = *suite.testStatuses["admin_account_status_1"]
@@ -459,7 +558,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
- "replies_count": 0,
+ "replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
@@ -583,7 +682,8 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
"aspect": 1.7821782
}
},
- "description": "A cow adorably licking another cow!"
+ "description": "A cow adorably licking another cow!",
+ "blurhash": null
}`, string(b))
}
diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go
index a99d9e7ae..a19588221 100644
--- a/internal/typeutils/util.go
+++ b/internal/typeutils/util.go
@@ -22,10 +22,17 @@ import (
"errors"
"fmt"
"net/url"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
"github.com/superseriousbusiness/gotosocial/internal/ap"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/regexes"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
)
type statusInteractions struct {
@@ -100,3 +107,80 @@ func getURI(withID ap.WithJSONLDId) (*url.URL, string, error) {
id := idProp.Get()
return id, id.String(), nil
}
+
+// placeholdUnknownAttachments separates any attachments with type `unknown`
+// out of the given slice, and returns an `