[chore] store basic error details when failing to process remote media (#4625)

- drops `cached` columns from both media and emoji tables, instead relying on empty path
- drops `processed` column from media table, as it was totally unused
- adds `error` uint32 column to both media and emoji tables, and tracks basic error details encountered when trying to process remote media / emoji
- updates our media / emoji processor getter functions to attempt retries only if error situation is expected to be non-permanent
- adds a new `error` field to api attachment models to include error details string if failed to download
- updates media placeholder text generation to include error details string

Todos:
- ~~update cleaner to handle the new manner of `cached` flag (i.e. paths being set)~~
- ~~update tests~~

Nice-to-haves in later PRs:
- add 'force' flag for admins on media / emoji getter endpoints to allow forcing retry regardless of error type

Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4625
Co-authored-by: kim <grufwub@gmail.com>
Co-committed-by: kim <grufwub@gmail.com>
This commit is contained in:
kim
2026-01-15 17:26:54 +01:00
committed by kim
parent b310e99b43
commit 42a231a98b
60 changed files with 2042 additions and 1033 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ func (l *list) ListAttachmentPaths(ctx context.Context) error {
for {
// Get next page of media attachments up to max ID.
medias, err := l.state.DB.GetAttachments(ctx, "", &page)
medias, err := l.state.DB.GetAttachments(ctx, &page)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return fmt.Errorf("failed to fetch media from database: %w", err)
}
+2 -1
View File
@@ -172,8 +172,9 @@ func Start(ctx context.Context) error {
log.Info(ctx, "done! exiting...")
}()
// Create maintenance router.
var err error
// Create maintenance router.
route, err = router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
+5
View File
@@ -992,6 +992,11 @@ definitions:
example: This is a picture of a kitten.
type: string
x-go-name: Description
error:
description: Error encountered while fetching remote media, if any.
example: network timeout
type: string
x-go-name: Error
id:
description: The ID of the attachment.
example: 01FC31DZT1AYWDZ8XTCRWRBYRK
+2 -1
View File
@@ -38,6 +38,7 @@ import (
// ExtractObjects will extract object TypeOrIRIs from given implementing interface.
func ExtractObjects(with WithObject) []TypeOrIRI {
// Extract the attached object (if any).
objProp := with.GetActivityStreamsObject()
if objProp == nil {
@@ -60,6 +61,7 @@ func ExtractObjects(with WithObject) []TypeOrIRI {
// ExtractInstrument will extract instrument TypeOrIRIs from given implementing interface.
func ExtractInstruments(with WithInstrument) []TypeOrIRI {
// Extract the attached instrument (if any).
instrProp := with.GetActivityStreamsInstrument()
if instrProp == nil {
@@ -800,7 +802,6 @@ func ExtractAttachment(i Attachmentable) (*gtsmodel.MediaAttachment, error) {
FileMeta: gtsmodel.FileMeta{
Focus: ExtractFocus(i),
},
Processing: gtsmodel.ProcessingStatusReceived,
}, nil
}
@@ -532,10 +532,10 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() {
"url": "http://thequeenisstillalive.technology/@her_fuckin_maj",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg",
"header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.webp",
"header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01G549FP8065NKWBPTWHP6Y3PD.jpg",
"header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01G549FP8065NKWBPTWHP6Y3PD.webp",
"header_description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
"header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3R",
"header_media_id": "01G549FP8065NKWBPTWHP6Y3PD",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
@@ -35,7 +35,7 @@ type MediaCleanupTestSuite struct {
func (suite *MediaCleanupTestSuite) TestMediaCleanup() {
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testAttachment.Cached)
suite.True(testAttachment.Cached())
// set up the request
recorder := httptest.NewRecorder()
@@ -50,7 +50,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanup() {
// the attachment should be updated in the database
if !testrig.WaitFor(func() bool {
if prunedAttachment, _ := suite.db.GetAttachmentByID(suite.T().Context(), testAttachment.ID); prunedAttachment != nil {
return !*prunedAttachment.Cached
return !prunedAttachment.Cached()
}
return false
}) {
@@ -60,7 +60,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanup() {
func (suite *MediaCleanupTestSuite) TestMediaCleanupNoArg() {
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testAttachment.Cached)
suite.True(testAttachment.Cached())
println("TIME: ", testAttachment.CreatedAt.String())
// set up the request
@@ -75,7 +75,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNoArg() {
if !testrig.WaitFor(func() bool {
if prunedAttachment, _ := suite.db.GetAttachmentByID(suite.T().Context(), testAttachment.ID); prunedAttachment != nil {
return !*prunedAttachment.Cached
return !prunedAttachment.Cached()
}
return false
}) {
@@ -85,7 +85,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNoArg() {
func (suite *MediaCleanupTestSuite) TestMediaCleanupNotOldEnough() {
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testAttachment.Cached)
suite.True(testAttachment.Cached())
// set up the request
recorder := httptest.NewRecorder()
@@ -105,12 +105,12 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNotOldEnough() {
suite.NoError(err)
// the media should still be cached
suite.True(*prunedAttachment.Cached)
suite.True(prunedAttachment.Cached())
}
func (suite *MediaCleanupTestSuite) TestMediaCleanupNegative() {
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testAttachment.Cached)
suite.True(testAttachment.Cached())
// Set up the request
recorder := httptest.NewRecorder()
+12 -12
View File
@@ -68,10 +68,22 @@ func (suite *FileserverTestSuite) SetupSuite() {
testrig.InitTestConfig()
testrig.InitTestLog()
}
func (suite *FileserverTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(
&suite.state,
@@ -96,18 +108,6 @@ func (suite *FileserverTestSuite) SetupSuite() {
suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil)
suite.fileServer = fileserver.New(suite.processor)
}
func (suite *FileserverTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
suite.testTokens = testrig.NewTestTokens()
suite.testApplications = testrig.NewTestApplications()
+20 -12
View File
@@ -75,18 +75,22 @@ func (suite *ServeFileTestSuite) GetFile(
func (suite *ServeFileTestSuite) UncacheAttachment(targetAttachment *gtsmodel.MediaAttachment) {
ctx := suite.T().Context()
cached := false
targetAttachment.Cached = &cached
if err := suite.db.UpdateByID(ctx, targetAttachment, targetAttachment.ID, "cached"); err != nil {
suite.FailNow(err.Error())
}
if err := suite.storage.Delete(ctx, targetAttachment.File.Path); err != nil {
suite.FailNow(err.Error())
}
if err := suite.storage.Delete(ctx, targetAttachment.Thumbnail.Path); err != nil {
suite.FailNow(err.Error())
}
targetAttachment.File.Path = ""
targetAttachment.Thumbnail.Path = ""
if err := suite.db.UpdateAttachment(ctx, targetAttachment,
"thumbnail_path",
"file_path",
); err != nil {
suite.FailNow(err.Error())
}
}
func (suite *ServeFileTestSuite) TestServeOriginalLocalFileOK() {
@@ -177,7 +181,8 @@ func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecache() {
suite.FailNow(err.Error())
}
// uncache the attachment so we'll have to refetch it from the 'remote' instance
// uncache the attachment so we'll have to
// refetch it from the 'remote' instance
suite.UncacheAttachment(targetAttachment)
code, headers, body := suite.GetFile(
@@ -200,7 +205,8 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecache() {
suite.FailNow(err.Error())
}
// uncache the attachment so we'll have to refetch it from the 'remote' instance
// uncache the attachment so we'll have to
// refetch it from the 'remote' instance
suite.UncacheAttachment(targetAttachment)
code, headers, body := suite.GetFile(
@@ -219,10 +225,11 @@ func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecacheNotFound() {
targetAttachment := &gtsmodel.MediaAttachment{}
*targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"]
// uncache the attachment *and* set the remote URL to something that will return a 404
// uncache the attachment *and* set the remote
// URL to something that will return a 404
suite.UncacheAttachment(targetAttachment)
targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee"
if err := suite.db.UpdateByID(suite.T().Context(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil {
if err := suite.db.UpdateAttachment(suite.T().Context(), targetAttachment); err != nil {
suite.FailNow(err.Error())
}
@@ -240,10 +247,11 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecacheNotFound() {
targetAttachment := &gtsmodel.MediaAttachment{}
*targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"]
// uncache the attachment *and* set the remote URL to something that will return a 404
// uncache the attachment *and* set the remote
// URL to something that will return a 404
suite.UncacheAttachment(targetAttachment)
targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee"
if err := suite.db.UpdateByID(suite.T().Context(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil {
if err := suite.db.UpdateAttachment(suite.T().Context(), targetAttachment); err != nil {
suite.FailNow(err.Error())
}
+14
View File
@@ -78,9 +78,11 @@ type AttachmentAttributesRequest struct {
//
// swagger:model attachment
type Attachment struct {
// The ID of the attachment.
// example: 01FC31DZT1AYWDZ8XTCRWRBYRK
ID string `json:"id"`
// The type of the attachment.
// enum:
// - unknown
@@ -90,31 +92,43 @@ type Attachment struct {
// - audio
// example: image
Type string `json:"type"`
// The location of the original full-size attachment.
// example: https://example.org/fileserver/some_id/attachments/some_id/original/attachment.jpeg
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"`
// 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"`
// 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
RemoteURL *string `json:"remote_url"`
// The location of a scaled-down preview of the attachment on the remote server.
// Only defined for instances other than our own.
// example: https://some-other-server.org/attachments/small/ahhhhh.jpeg
PreviewRemoteURL *string `json:"preview_remote_url"`
// Metadata for this attachment.
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"`
// Error encountered while fetching remote media, if any.
// example: network timeout
Error *string `json:"error,omitempty"`
}
// WebAttachment is like Attachment, but with
-2
View File
@@ -348,7 +348,6 @@ func sizeofEmoji() uintptr {
URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
VisibleInPicker: func() *bool { ok := true; return &ok }(),
CategoryID: "01GGQ8V4993XK67B2JB396YFB7",
Cached: func() *bool { ok := true; return &ok }(),
}))
}
@@ -490,7 +489,6 @@ func sizeofMedia() uintptr {
},
Avatar: func() *bool { ok := false; return &ok }(),
Header: func() *bool { ok := false; return &ok }(),
Cached: func() *bool { ok := true; return &ok }(),
}))
}
+1 -1
View File
@@ -28,7 +28,7 @@ import (
// plus1hULID returns a ULID for now+1h.
func plus1hULID() string {
t := time.Now().Add(time.Hour)
return id.NewULIDFromTime(t)
return id.ZeroULIDForTime(t)
}
// nextPageParams gets the next set of paging
+38 -48
View File
@@ -27,9 +27,8 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"code.superseriousbusiness.org/gotosocial/internal/storage"
"code.superseriousbusiness.org/gotosocial/internal/util"
)
// Emoji encompasses a set of
@@ -107,37 +106,42 @@ func (e *Emoji) LogFixCacheStates(ctx context.Context) {
// will be checked for `gtscontext.DryRun()` in order to actually perform the action.
func (e *Emoji) UncacheRemote(ctx context.Context, olderThan time.Time) (int, error) {
var total int
var page paging.Page
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
// Drop time by a minute to improve search,
// (i.e. make it olderThan inclusive search).
olderThan = olderThan.Add(-time.Minute)
// Store recent time.
mostRecent := olderThan
// Get ULID for 'olderThan' to use as maxID.
olderThanID := id.ZeroULIDForTime(olderThan)
page.Max.Value = olderThanID
for {
// Fetch the next batch of cached emojis older than last-set time.
emojis, err := e.state.DB.GetCachedEmojisOlderThan(ctx, olderThan, selectLimit)
// Fetch next batch of cached emojis older than maxID.
emojis, err := e.state.DB.GetCachedEmojis(ctx, &page)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return total, gtserror.Newf("error getting remote emoji: %w", err)
}
// If no emojis / same group is
// returned, we reached the end.
if len(emojis) == 0 ||
olderThan.Equal(emojis[len(emojis)-1].CreatedAt) {
// Get current max ID.
maxID := page.Max.Value
// If none or the same group is returned, we reached the end.
if len(emojis) == 0 || maxID == emojis[len(emojis)-1].ID {
break
}
// Use last createdAt as next 'olderThan' value.
olderThan = emojis[len(emojis)-1].CreatedAt
// Use last ID as next 'maxID' value.
maxID = emojis[len(emojis)-1].ID
page.Max.Value = maxID
for _, emoji := range emojis {
// Check / uncache each remote emoji.
uncached, err := e.uncacheRemote(ctx,
mostRecent,
emoji,
)
// Check and try uncache each remote emoji media model.
uncached, err := e.uncacheRemote(ctx, olderThan, emoji)
if err != nil {
return total, err
}
@@ -199,34 +203,17 @@ func (e *Emoji) PurgeRemote(ctx context.Context, domain string) (int, error) {
}
for _, emoji := range emojis {
if emoji.ImagePath != "" {
// Ensure emoji file at path is deleted from storage.
err := e.state.Storage.Delete(ctx, emoji.ImagePath)
if err != nil && !storage.IsNotFound(err) {
log.Errorf(ctx, "error deleting %s: %v", emoji.ImagePath, err)
}
}
if emoji.ImageStaticPath != "" {
// Ensure emoji static file at path is deleted from storage.
err := e.state.Storage.Delete(ctx, emoji.ImageStaticPath)
if err != nil && !storage.IsNotFound(err) {
log.Errorf(ctx, "error deleting %s: %v", emoji.ImageStaticPath, err)
}
// Remove emoji and static files.
_, err := e.removeFiles(ctx,
emoji.ImageStaticPath,
emoji.ImagePath,
)
if err != nil {
log.Errorf(ctx, "error removing emoji files: %v", err)
}
// Unset fields.
emoji.ImageStaticContentType = ""
emoji.ImageStaticFileSize = 0
emoji.ImageStaticPath = ""
emoji.ImageStaticURL = ""
emoji.ImageContentType = ""
emoji.ImageFileSize = 0
emoji.ImagePath = ""
emoji.ImageURL = ""
// Ensure marked as not cached.
emoji.Cached = util.Ptr(false)
emoji.Stub()
// Update.
if err := e.state.DB.UpdateEmoji(ctx, emoji); err != nil {
@@ -433,13 +420,13 @@ func (e *Emoji) fixCacheState(ctx context.Context, emoji *gtsmodel.Emoji) (bool,
return false, err
}
switch {
case *emoji.Cached && !exist:
switch cached := emoji.Cached(); {
case cached && !exist:
// Mark as uncached if expected files don't exist.
l.Debug("cached=true exists=false => marking uncached")
return true, e.uncache(ctx, emoji)
case !*emoji.Cached && exist:
case !cached && exist:
// Remove files if we don't expect them to exist.
l.Debug("cached=false exists=true => removing files")
_, err := e.removeFiles(ctx,
@@ -454,7 +441,7 @@ func (e *Emoji) fixCacheState(ctx context.Context, emoji *gtsmodel.Emoji) (bool,
}
func (e *Emoji) uncacheRemote(ctx context.Context, after time.Time, emoji *gtsmodel.Emoji) (bool, error) {
if !*emoji.Cached {
if !emoji.Cached() {
// Already uncached.
return false, nil
}
@@ -588,8 +575,11 @@ func (e *Emoji) uncache(ctx context.Context, emoji *gtsmodel.Emoji) error {
// Update emoji to reflect that we no longer have it cached.
log.Debugf(ctx, "marking emoji as uncached: %s", emoji.ID)
emoji.Cached = func() *bool { i := false; return &i }()
if err := e.state.DB.UpdateEmoji(ctx, emoji, "cached"); err != nil {
emoji.ImagePath, emoji.ImageStaticPath = "", ""
if err := e.state.DB.UpdateEmoji(ctx, emoji,
"image_static_path",
"image_path",
); err != nil {
return gtserror.Newf("error updating emoji: %w", err)
}
+10 -9
View File
@@ -9,7 +9,6 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/util"
)
func copyMap(in map[string]*gtsmodel.Emoji) map[string]*gtsmodel.Emoji {
@@ -71,7 +70,9 @@ func (suite *CleanerTestSuite) TestEmojiFixCacheStates() {
// rainbow emoji as uncached
// so there's something to fix.
emojis := copyMap(suite.emojis)
emojis["rainbow"].Cached = util.Ptr(false)
emoji := emojis["rainbow"]
emoji.ImageStaticPath = ""
emoji.ImagePath = ""
suite.testEmojiFixCacheStates(
suite.T().Context(),
@@ -84,7 +85,9 @@ func (suite *CleanerTestSuite) TestEmojiFixCacheStatesDryRun() {
// rainbow emoji as uncached
// so there's something to fix.
emojis := copyMap(suite.emojis)
emojis["rainbow"].Cached = util.Ptr(false)
emoji := emojis["rainbow"]
emoji.ImageStaticPath = ""
emoji.ImagePath = ""
suite.testEmojiFixCacheStates(
gtscontext.SetDryRun(suite.T().Context()),
@@ -141,7 +144,7 @@ func (suite *CleanerTestSuite) testEmojiUncacheRemote(ctx context.Context, emoji
}
// Check cache state.
if *emoji.Cached {
if emoji.Cached() {
t.Errorf("emoji %s@%s should have been uncached", emoji.Shortcode, emoji.Domain)
}
@@ -164,7 +167,7 @@ func (suite *CleanerTestSuite) shouldUncacheEmoji(ctx context.Context, emoji *gt
return false, nil
}
if emoji.Cached == nil || !*emoji.Cached {
if !emoji.Cached() {
// Emoji is already uncached.
return false, nil
}
@@ -408,14 +411,12 @@ func (suite *CleanerTestSuite) shouldFixEmojiCacheState(ctx context.Context, emo
}
switch exists := (haveImage && haveStatic); {
case emoji.Cached != nil &&
*emoji.Cached && !exists:
case emoji.Cached() && !exists:
// (cached can be nil in tests)
// Cached but missing files.
return true, nil
case emoji.Cached != nil &&
!*emoji.Cached && exists:
case !emoji.Cached() && exists:
// (cached can be nil in tests)
// Uncached but unexpected files.
return true, nil
+80 -93
View File
@@ -28,12 +28,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/media"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"code.superseriousbusiness.org/gotosocial/internal/regexes"
"code.superseriousbusiness.org/gotosocial/internal/storage"
"code.superseriousbusiness.org/gotosocial/internal/uris"
"code.superseriousbusiness.org/gotosocial/internal/util"
)
// Media encompasses a set of
@@ -145,17 +144,16 @@ func (m *Media) PruneOrphaned(ctx context.Context) (int, error) {
// Media is marked as unused if not attached to any status, account or account is suspended.
// Context will be checked for `gtscontext.DryRun()` in order to actually perform the action.
func (m *Media) PruneUnused(ctx context.Context) (int, error) {
var (
total int
page paging.Page
)
var total int
var page paging.Page
// Set page select limit.
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
for {
// Fetch the next batch of media attachments to next maxID.
attachments, err := m.state.DB.GetAttachments(ctx, "", &page)
attachments, err := m.state.DB.GetAttachments(ctx, &page)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return total, gtserror.Newf("error getting attachments: %w", err)
}
@@ -170,7 +168,7 @@ func (m *Media) PruneUnused(ctx context.Context) (int, error) {
// Use last ID as the next 'maxID' value.
maxID = attachments[len(attachments)-1].ID
page.Max = paging.MaxID(maxID)
page.Max.Value = maxID
for _, media := range attachments {
// Check / prune unused media attachment.
@@ -194,33 +192,42 @@ func (m *Media) PruneUnused(ctx context.Context) (int, error) {
// Context will be checked for `gtscontext.DryRun()` in order to actually perform the action.
func (m *Media) UncacheRemote(ctx context.Context, olderThan time.Time) (int, error) {
var total int
var page paging.Page
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
// Drop time by a minute to improve search,
// (i.e. make it olderThan inclusive search).
olderThan = olderThan.Add(-time.Minute)
// Store recent time.
mostRecent := olderThan
// Get ULID for 'olderThan' to use as maxID.
olderThanID := id.ZeroULIDForTime(olderThan)
page.Max.Value = olderThanID
for {
// Fetch the next batch of cached attachments older than last-set time.
attachments, err := m.state.DB.GetCachedAttachmentsOlderThan(ctx, olderThan, selectLimit)
// Fetch the next batch of cached attachments older than maxID.
attachments, err := m.state.DB.GetCachedAttachments(ctx, &page)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return total, gtserror.Newf("error getting remote attachments: %w", err)
}
// If no attachments / same group is returned, we reached the end.
if len(attachments) == 0 ||
olderThan.Equal(attachments[len(attachments)-1].CreatedAt) {
// Get current max ID.
maxID := page.Max.Value
// If no attachments or the same group is returned, we reached the end.
if len(attachments) == 0 || maxID == attachments[len(attachments)-1].ID {
break
}
// Use last created-at as the next 'olderThan' value.
olderThan = attachments[len(attachments)-1].CreatedAt
// Use last ID as the next 'maxID' value.
maxID = attachments[len(attachments)-1].ID
page.Max.Value = maxID
for _, media := range attachments {
// Check / uncache each remote media attachment.
uncached, err := m.uncacheRemote(ctx, mostRecent, media)
// Check and try uncache each remote media attachment.
uncached, err := m.uncacheRemote(ctx, olderThan, media)
if err != nil {
return total, err
}
@@ -239,20 +246,16 @@ func (m *Media) UncacheRemote(ctx context.Context, olderThan time.Time) (int, er
// PurgeRemote stubs + uncaches all remote media from the given domain.
// Context will be checked for `gtscontext.DryRun()` in order to actually perform the action.
func (m *Media) PurgeRemote(ctx context.Context, domain string) (int, error) {
var (
total int
page paging.Page
accounts []*gtsmodel.Account
err error
)
var total int
var page paging.Page
// Set page select limit.
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
for {
// Get (next) page of accounts
// from the target domain.
accounts, err = m.state.DB.GetAccounts(
// Get (next) page of accounts for domain.
accounts, err := m.state.DB.GetAccounts(
gtscontext.SetBarebones(ctx),
"", // origin
"", // status
@@ -278,7 +281,7 @@ func (m *Media) PurgeRemote(ctx context.Context, domain string) (int, error) {
// Set params for next page.
loAcct := accounts[count-1]
lo := loAcct.Domain + "/@" + loAcct.Username
page.Max = paging.MaxID(lo)
page.Max.Value = lo
// For each account, stub all
// that account's attachments.
@@ -296,19 +299,16 @@ func (m *Media) PurgeRemote(ctx context.Context, domain string) (int, error) {
// stubAccountAttachments stubs all attachments belonging to the given accountID.
func (m *Media) stubAccountAttachments(ctx context.Context, accountID string) (int, error) {
var (
total int
page paging.Page
attachments []*gtsmodel.MediaAttachment
err error
)
var total int
var page paging.Page
// Set page select limit.
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
for {
// Get (next) page of attachments from the account.
attachments, err = m.state.DB.GetAttachments(
// Get (next) page of attachments from db for the account.
attachments, err := m.state.DB.GetAttachmentsByAccountID(
ctx,
accountID,
&page,
@@ -317,17 +317,19 @@ func (m *Media) stubAccountAttachments(ctx context.Context, accountID string) (i
return total, gtserror.Newf("db error getting attachments: %w", err)
}
count := len(attachments)
if count == 0 {
// We're done.
// Get current max ID.
maxID := page.Max.Value
// If no attachments or the same group is returned, we reached the end.
if len(attachments) == 0 || maxID == attachments[len(attachments)-1].ID {
break
}
// Set params for next page.
maxID := attachments[count-1].ID
page.Max = paging.MaxID(maxID)
// Use last ID as the next 'maxID' value.
maxID = attachments[len(attachments)-1].ID
page.Max.Value = maxID
total += count
total += len(attachments)
if gtscontext.DryRun(ctx) {
// If this is a dry run, just increment
// the total by the attachment count
@@ -336,9 +338,9 @@ func (m *Media) stubAccountAttachments(ctx context.Context, accountID string) (i
}
// Stub each attachment by removing it
// from storage if possible, and stubbing
// its fields, leaving description, blurhash,
// and remoteURL metadata in place.
// from storage, and stubbing it fields,
// leaving description, blurhash, and
// remoteURL metadata in place.
for _, a := range attachments {
if err := m.stubAttachment(ctx, a); err != nil {
return total, err
@@ -349,43 +351,26 @@ func (m *Media) stubAccountAttachments(ctx context.Context, accountID string) (i
return total, nil
}
// stubAttachment removes stored media + stubs data for one attachment.
// stubAttachment removes stored media and stubs all available fields for given attachment.
func (m *Media) stubAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) error {
if a.File.Path != "" {
// Ensure media file at path is deleted from storage.
err := m.state.Storage.Delete(ctx, a.File.Path)
if err != nil && !storage.IsNotFound(err) {
log.Errorf(ctx, "error deleting %s: %v", a.File.Path, err)
}
// Remove any attachment files.
if _, err := m.removeFiles(ctx,
a.Thumbnail.Path,
a.File.Path,
); err != nil {
log.Error(ctx, err)
}
if a.Thumbnail.Path != "" {
// Ensure media thumbnail at path is deleted from storage.
err := m.state.Storage.Delete(ctx, a.Thumbnail.Path)
if err != nil && !storage.IsNotFound(err) {
log.Errorf(ctx, "error deleting %s: %v", a.Thumbnail.Path, err)
}
}
// Unset
// fields.
a.Stub()
// Unset all file fields.
a.FileMeta.Original = gtsmodel.Original{}
a.FileMeta.Small = gtsmodel.Small{}
a.File.ContentType = ""
a.File.FileSize = 0
a.File.Path = ""
a.Thumbnail.FileSize = 0
a.Thumbnail.ContentType = ""
a.Thumbnail.Path = ""
a.Thumbnail.URL = ""
a.URL = ""
// Also ensure marked as unknown and finished
// processing so gets inserted as placeholder URL.
a.Processing = gtsmodel.ProcessingStatusProcessed
// Also ensure marked as unknown so
// gets inserted as placeholder URL.
a.Type = gtsmodel.FileTypeUnknown
a.Cached = util.Ptr(false)
// Update the stubbed attachment.
// Update the stubbed attachment in the database.
if err := m.state.DB.UpdateAttachment(ctx, a); err != nil {
return gtserror.Newf("db error updating attachment: %w", err)
}
@@ -397,12 +382,11 @@ func (m *Media) stubAttachment(ctx context.Context, a *gtsmodel.MediaAttachment)
// Media marked as cached, with any required files missing, will be automatically uncached.
// Context will be checked for `gtscontext.DryRun()` in order to actually perform the action.
func (m *Media) FixCacheStates(ctx context.Context) (int, error) {
var (
total int
page paging.Page
)
var total int
var page paging.Page
// Set page select limit.
// Setup page w/ select limit.
page.Max = paging.MaxID("")
page.Limit = selectLimit
for {
@@ -421,7 +405,7 @@ func (m *Media) FixCacheStates(ctx context.Context) (int, error) {
// Use last ID as the next 'maxID' value.
maxID = attachments[len(attachments)-1].ID
page.Max = paging.MaxID(maxID)
page.Max.Value = maxID
for _, media := range attachments {
// Check / fix required media cache states.
@@ -607,13 +591,13 @@ func (m *Media) fixCacheState(ctx context.Context, media *gtsmodel.MediaAttachme
return false, err
}
switch {
case *media.Cached && !exist:
switch cached := media.Cached(); {
case cached && !exist:
// Mark as uncached if expected files don't exist.
l.Debug("cached=true exists=false => uncaching")
return true, m.uncache(ctx, media)
case !*media.Cached && exist:
case !cached && exist:
// Remove files if we don't expect them to exist.
l.Debug("cached=false exists=true => deleting")
_, err := m.removeFiles(ctx,
@@ -628,7 +612,7 @@ func (m *Media) fixCacheState(ctx context.Context, media *gtsmodel.MediaAttachme
}
func (m *Media) uncacheRemote(ctx context.Context, after time.Time, media *gtsmodel.MediaAttachment) (bool, error) {
if !*media.Cached {
if !media.Cached() {
// Already uncached.
return false, nil
}
@@ -778,8 +762,11 @@ func (m *Media) uncache(ctx context.Context, media *gtsmodel.MediaAttachment) er
// Update attachment to reflect that we no longer have it cached.
log.Debugf(ctx, "marking media attachment as uncached: %s", media.ID)
media.Cached = func() *bool { i := false; return &i }()
if err := m.state.DB.UpdateAttachment(ctx, media, "cached"); err != nil {
media.File.Path, media.Thumbnail.Path = "", ""
if err := m.state.DB.UpdateAttachment(ctx, media,
"thumbnail_path",
"file_path",
); err != nil {
return gtserror.Newf("error updating media: %w", err)
}
+10 -10
View File
@@ -90,10 +90,10 @@ func (suite *MediaTestSuite) TestUncacheRemote() {
ctx := suite.T().Context()
testStatusAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testStatusAttachment.Cached)
suite.True(testStatusAttachment.Cached())
testHeader := suite.testAttachments["remote_account_3_header"]
suite.True(*testHeader.Cached)
suite.True(testHeader.Cached())
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(ctx, after)
@@ -102,11 +102,11 @@ func (suite *MediaTestSuite) TestUncacheRemote() {
uncachedAttachment, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
suite.False(*uncachedAttachment.Cached)
suite.False(uncachedAttachment.Cached())
uncachedAttachment, err = suite.db.GetAttachmentByID(ctx, testHeader.ID)
suite.NoError(err)
suite.False(*uncachedAttachment.Cached)
suite.False(uncachedAttachment.Cached())
}
func (suite *MediaTestSuite) TestPurgeRemote() {
@@ -148,10 +148,10 @@ func (suite *MediaTestSuite) TestUncacheRemoteDry() {
ctx := suite.T().Context()
testStatusAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
suite.True(*testStatusAttachment.Cached)
suite.True(testStatusAttachment.Cached())
testHeader := suite.testAttachments["remote_account_3_header"]
suite.True(*testHeader.Cached)
suite.True(testHeader.Cached())
after := time.Now().Add(-24 * time.Hour)
totalUncached, err := suite.cleaner.Media().UncacheRemote(gtscontext.SetDryRun(ctx), after)
@@ -160,11 +160,11 @@ func (suite *MediaTestSuite) TestUncacheRemoteDry() {
uncachedAttachment, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
suite.True(*uncachedAttachment.Cached)
suite.True(uncachedAttachment.Cached())
uncachedAttachment, err = suite.db.GetAttachmentByID(ctx, testHeader.ID)
suite.NoError(err)
suite.True(*uncachedAttachment.Cached)
suite.True(uncachedAttachment.Cached())
}
func (suite *MediaTestSuite) TestUncacheRemoteTwice() {
@@ -223,7 +223,7 @@ func (suite *MediaTestSuite) TestUncacheAndRecache() {
suite.NotNil(recachedAttachment)
// recachedAttachment should be basically the same as the old attachment
suite.True(*recachedAttachment.Cached)
suite.True(recachedAttachment.Cached())
suite.Equal(original.ID, recachedAttachment.ID)
suite.Equal(original.File.Path, recachedAttachment.File.Path) // file should be stored in the same place
suite.Equal(original.Thumbnail.Path, recachedAttachment.Thumbnail.Path) // as should the thumbnail
@@ -244,7 +244,7 @@ func (suite *MediaTestSuite) TestUncacheOneNonExistent() {
// Delete this attachment cached on disk
media, err := suite.db.GetAttachmentByID(ctx, testStatusAttachment.ID)
suite.NoError(err)
suite.True(*media.Cached)
suite.True(media.Cached())
err = suite.storage.Delete(ctx, media.File.Path)
suite.NoError(err)
+1 -1
View File
@@ -175,7 +175,7 @@ func Validate() error {
// Get and check configured max thumb size.
switch max := GetMediaThumbMaxPixels(); {
case max < minThumb:
errf("%s < 32 is not a useable thumbsize", MediaThumbMaxPixelsFlag, max)
errf("%s < 32 is not a useable thumbsize", MediaThumbMaxPixelsFlag)
case max < minThumbRecc:
log.Warnf(nil, "%s smaller than min recommended thumbsize %d", MediaThumbMaxPixelsFlag, minThumbRecc)
case max > maxThumbRecc:
+53 -58
View File
@@ -305,78 +305,73 @@ func (e *emojiDB) GetEmojisBy(ctx context.Context, domain string, includeDisable
}
func (e *emojiDB) GetEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error) {
maxID := page.GetMax()
limit := page.GetLimit()
emojiIDs := make([]string, 0, limit)
q := e.db.NewSelect().
Table("emojis").
Column("id").
Order("id DESC")
if maxID != "" {
q = q.Where("id < ?", maxID)
}
if limit != 0 {
q = q.Limit(limit)
}
if err := q.Scan(ctx, &emojiIDs); err != nil {
return nil, err
}
return e.GetEmojisByIDs(ctx, emojiIDs)
return e.getEmojisPagedByID(ctx, nil, page)
}
func (e *emojiDB) GetRemoteEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error) {
maxID := page.GetMax()
limit := page.GetLimit()
emojiIDs := make([]string, 0, limit)
q := e.db.NewSelect().
Table("emojis").
Column("id").
Where("domain IS NOT NULL").
Order("id DESC")
if maxID != "" {
q = q.Where("id < ?", maxID)
}
if limit != 0 {
q = q.Limit(limit)
}
if err := q.Scan(ctx, &emojiIDs); err != nil {
return nil, err
}
return e.GetEmojisByIDs(ctx, emojiIDs)
return e.getEmojisPagedByID(ctx, func(q *bun.SelectQuery) *bun.SelectQuery {
q = q.Where("domain IS NOT NULL")
return q
}, page)
}
func (e *emojiDB) GetCachedEmojisOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.Emoji, error) {
var emojiIDs []string
func (e *emojiDB) GetCachedEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error) {
return e.getEmojisPagedByID(ctx, func(q *bun.SelectQuery) *bun.SelectQuery {
q = q.Where("domain IS NOT NULL")
q = q.Where("image_static_path IS NOT ?", "")
q = q.Where("image_path IS NOT ?", "")
return q
}, page)
}
func (e *emojiDB) getEmojisPagedByID(ctx context.Context, query func(*bun.SelectQuery) *bun.SelectQuery, page *paging.Page) ([]*gtsmodel.Emoji, error) {
maxID := page.GetMax()
minID := page.GetMin()
limit := page.GetLimit()
order := page.GetOrder()
// Pre-allocate slice of dest IDs.
ids := make([]string, 0, limit)
// Start building query.
q := e.db.NewSelect().
Table("emojis").
Column("id").
Where("cached = true").
Where("domain IS NOT NULL").
Where("created_at < ?", olderThan).
Order("created_at DESC")
Column("id")
if limit != 0 {
q = q.Limit(limit)
if query != nil {
// Append caller
// query details.
q = query(q)
}
if err := q.Scan(ctx, &emojiIDs); err != nil {
if maxID != "" {
// Set a maximum ID boundary if was given.
q = q.Where("? < ?", bun.Ident("id"), maxID)
}
if minID != "" {
// Set a minimum ID boundary if was given.
q = q.Where("? > ?", bun.Ident("id"), minID)
}
// Set query ordering.
if order.Ascending() {
q = q.OrderExpr("? ASC", bun.Ident("id"))
} else /* i.e. descending */ {
q = q.OrderExpr("? DESC", bun.Ident("id"))
}
// A limit should always
// be supplied for this.
q = q.Limit(limit)
// Finally, perform query into IDs slice.
if err := q.Scan(ctx, &ids); err != nil {
return nil, err
}
return e.GetEmojisByIDs(ctx, emojiIDs)
// Fetch emoji from DB with IDs.
return e.GetEmojisByIDs(ctx, ids)
}
func (e *emojiDB) GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, error) {
+54 -60
View File
@@ -21,7 +21,6 @@ import (
"context"
"errors"
"slices"
"time"
"code.superseriousbusiness.org/gopkg/xslices"
"code.superseriousbusiness.org/gotosocial/internal/db"
@@ -224,82 +223,77 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
return err
}
func (m *mediaDB) GetAttachments(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
maxID := page.GetMax()
limit := page.GetLimit()
func (m *mediaDB) GetAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
return m.getAttachmentsPagedByID(ctx, nil, page)
}
attachmentIDs := make([]string, 0, limit)
q := m.db.NewSelect().
Table("media_attachments").
Column("id").
Order("id DESC")
if accountID != "" {
q = q.Where("? = ?", bun.Ident("account_id"), accountID)
}
if maxID != "" {
q = q.Where("? < ?", bun.Ident("id"), maxID)
}
if limit != 0 {
q = q.Limit(limit)
}
if err := q.Scan(ctx, &attachmentIDs); err != nil {
return nil, err
}
return m.GetAttachmentsByIDs(ctx, attachmentIDs)
func (m *mediaDB) GetAttachmentsByAccountID(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
return m.getAttachmentsPagedByID(ctx, func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("? = ?", bun.Ident("account_id"), accountID)
}, page)
}
func (m *mediaDB) GetRemoteAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
return m.getAttachmentsPagedByID(ctx, func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("remote_url IS NOT NULL")
}, page)
}
func (m *mediaDB) GetCachedAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
return m.getAttachmentsPagedByID(ctx, func(q *bun.SelectQuery) *bun.SelectQuery {
q = q.Where("remote_url IS NOT NULL")
q = q.Where("file_path IS NOT ?", "")
q = q.Where("thumbnail_path IS NOT ?", "")
return q
}, page)
}
func (m *mediaDB) getAttachmentsPagedByID(ctx context.Context, query func(*bun.SelectQuery) *bun.SelectQuery, page *paging.Page) ([]*gtsmodel.MediaAttachment, error) {
maxID := page.GetMax()
minID := page.GetMin()
limit := page.GetLimit()
order := page.GetOrder()
attachmentIDs := make([]string, 0, limit)
// Pre-allocate slice of dest IDs.
ids := make([]string, 0, limit)
// Start building query.
q := m.db.NewSelect().
Table("media_attachments").
Column("id").
Where("remote_url IS NOT NULL").
Order("id DESC")
Column("id")
if query != nil {
// Append caller
// query details.
q = query(q)
}
if maxID != "" {
q = q.Where("id < ?", maxID)
// Set a maximum ID boundary if was given.
q = q.Where("? < ?", bun.Ident("id"), maxID)
}
if limit != 0 {
q = q.Limit(limit)
if minID != "" {
// Set a minimum ID boundary if was given.
q = q.Where("? > ?", bun.Ident("id"), minID)
}
if err := q.Scan(ctx, &attachmentIDs); err != nil {
// Set query ordering.
if order.Ascending() {
q = q.OrderExpr("? ASC", bun.Ident("id"))
} else /* i.e. descending */ {
q = q.OrderExpr("? DESC", bun.Ident("id"))
}
// A limit should always
// be supplied for this.
q = q.Limit(limit)
// Finally, perform query into IDs slice.
if err := q.Scan(ctx, &ids); err != nil {
return nil, err
}
return m.GetAttachmentsByIDs(ctx, attachmentIDs)
}
func (m *mediaDB) GetCachedAttachmentsOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, error) {
attachmentIDs := make([]string, 0, limit)
q := m.db.
NewSelect().
Table("media_attachments").
Column("id").
Where("cached = true").
Where("remote_url IS NOT NULL").
Where("created_at < ?", olderThan).
Order("created_at DESC")
if limit != 0 {
q = q.Limit(limit)
}
if err := q.Scan(ctx, &attachmentIDs); err != nil {
return nil, err
}
return m.GetAttachmentsByIDs(ctx, attachmentIDs)
// Fetch media from DB with given IDs.
return m.GetAttachmentsByIDs(ctx, ids)
}
+3 -9
View File
@@ -21,6 +21,7 @@ import (
"testing"
"time"
"code.superseriousbusiness.org/gotosocial/internal/id"
"github.com/stretchr/testify/suite"
)
@@ -35,16 +36,9 @@ func (suite *MediaTestSuite) TestGetAttachmentByID() {
suite.NotNil(attachment)
}
func (suite *MediaTestSuite) TestGetOlder() {
attachments, err := suite.db.GetCachedAttachmentsOlderThan(suite.T().Context(), time.Now(), 20)
suite.NoError(err)
suite.Len(attachments, 3)
}
func (suite *MediaTestSuite) TestGetCachedAttachmentsOlderThan() {
ctx := suite.T().Context()
attachments, err := suite.db.GetCachedAttachmentsOlderThan(ctx, time.Now(), 20)
olderThanID := id.ZeroULIDForTime(time.Now())
attachments, err := suite.db.GetCachedAttachments(suite.T().Context(), toPage(olderThanID, "", "", 20))
suite.NoError(err)
suite.Len(attachments, 3)
}
@@ -31,8 +31,8 @@ func init() {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Generate new Account.Indexable column definition from bun.
statusType := reflect.TypeOf((*gtsmodel.Account)(nil))
colDef, err := getBunColumnDef(tx, statusType, "Indexable")
accountType := reflect.TypeOf((*gtsmodel.Account)(nil))
colDef, err := getBunColumnDef(tx, accountType, "Indexable")
if err != nil {
return err
}
@@ -0,0 +1,106 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
newmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20251208134945_media_cleanup/newmodel"
oldmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20251208134945_media_cleanup/oldmodel"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Add new error columns to the database.
for model, field := range map[any]string{
(*newmodel.MediaAttachment)(nil): "Error",
(*newmodel.Emoji)(nil): "Error",
} {
if err := addColumn(ctx, tx, model, field); err != nil {
return err
}
}
// Drop old media cleanup index that relies on below dropped columns.
if err := dropIndex(ctx, tx, "media_attachments_cleanup_idx"); err != nil {
return err
}
// Create new media cleanup index.
if err := createIndex(ctx, tx,
"media_attachments_cleanup_idx",
"media_attachments",
"?, ?", bun.Ident("file_path"), bun.Ident("thumbnail_path"),
); err != nil {
return err
}
// Unset all file paths for media
// attachments that are uncached,
// to match new caching strategy.
if _, err := tx.NewUpdate().
Table("media_attachments").
Where("? IS NULL OR ? = false", bun.Ident("cached"), bun.Ident("cached")).
Set("? = ?", bun.Ident("thumbnail_path"), "").
Set("? = ?", bun.Ident("file_path"), "").
Exec(ctx); err != nil {
return gtserror.Newf("error updating uncached media: %w", err)
}
// Unset all file paths for emoji
// attachments that are uncached,
// to match new caching strategy.
if _, err := tx.NewUpdate().
Table("emojis").
Where("? IS NULL OR ? = false", bun.Ident("cached"), bun.Ident("cached")).
Set("? = ?", bun.Ident("image_static_path"), "").
Set("? = ?", bun.Ident("image_path"), "").
Exec(ctx); err != nil {
return gtserror.Newf("error updating uncached emojis: %w", err)
}
// Drop (now) unused columns from database.
for model, fields := range map[any][]string{
(*oldmodel.MediaAttachment)(nil): {"Cached", "Processing"},
(*oldmodel.Emoji)(nil): {"Cached"},
} {
for _, field := range fields {
if err := dropColumn(ctx, tx, model, field); err != nil {
return err
}
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}
@@ -15,8 +15,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
package gtsmodel
import "code.superseriousbusiness.org/gotosocial/internal/id"
// smallint is the largest size supported
// by a PostgreSQL SMALLINT, since an SQLite
// SMALLINT is actually variable in size.
type smallint int16
func main() { println(id.NewULID()) }
// enumType is the type we (at least, should) use
// for database enum types, as smallest int size.
type enumType smallint
@@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// Emoji represents a custom emoji that's been uploaded
// through the admin UI or downloaded from a remote instance.
type Emoji struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain.
Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis.
ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis.
ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system.
ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system
ImageContentType string `bun:",notnull"` // MIME content type of the emoji image
ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image.
ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes.
ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes.
Error MediaErrorDetails `bun:",nullzero,notnull,default:0"` // Details about any error encountered downloading file
Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown?
URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234'
VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker?
CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to.
}
@@ -0,0 +1,96 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
)
// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
// somewhere in storage and that can be retrieved and served by the router.
type MediaAttachment struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
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:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown)
Error MediaErrorDetails `bun:",nullzero,notnull,default:0"` // Details about any error encountered downloading file
FileMeta FileMeta `bun:",embed:,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)
ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment
File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file
Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file.
Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar?
Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header?
}
// File refers to the metadata for the whole file.
type File struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
}
// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
type Thumbnail struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
URL string `bun:",nullzero"` // What is the URL of the thumbnail on the local server
RemoteURL string `bun:",nullzero"` // What is the remote URL of the thumbnail (empty for local media)
}
// FileType refers to the file
// type of the media attaachment.
type FileType enumType
// FileMeta describes metadata about the actual contents of the file.
type FileMeta struct {
Original Original `bun:"embed:original_"`
Small Small `bun:"embed:small_"`
Focus Focus `bun:"embed:focus_"`
}
// Small can be used for a thumbnail of any media type
type Small struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
}
// Original can be used for original metadata for any media type
type Original struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
Duration *float32 // video-specific: duration of the video in seconds
Framerate *float32 // video-specific: fps
Bitrate *uint64 // video-specific: bitrate
}
// Focus describes the 'center' of the image for display purposes.
// X and Y should each be between -1 and 1
type Focus struct {
X float32
Y float32
}
@@ -0,0 +1,28 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
// MediaErrorDetails stores basic error details about
// why a piece of media may not have been downloaded.
// It contains a 16bit MediaErrorType, and the remaining
// 16bits may contain optional extra error details.
type MediaErrorDetails uint32
// MediaErrorType describes a broad error type for why
// one or more media files may not have been downloaded.
type MediaErrorType uint16
@@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// Emoji represents a custom emoji that's been uploaded
// through the admin UI or downloaded from a remote instance.
type Emoji struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain.
Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis.
ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis.
ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system.
ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system
ImageContentType string `bun:",notnull"` // MIME content type of the emoji image
ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image.
ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes.
ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes.
Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown?
URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234'
VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker?
CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to.
Cached *bool `bun:",nullzero,notnull,default:false"` // whether emoji is cached in locally in gotosocial storage.
}
@@ -0,0 +1,100 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
)
// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
// somewhere in storage and that can be retrieved and served by the router.
type MediaAttachment struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
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:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown)
FileMeta FileMeta `bun:",embed:,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)
ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment
Processing ProcessingStatus `bun:",notnull,default:2"` // What is the processing status of this attachment
File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file
Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file.
Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar?
Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header?
Cached *bool `bun:",nullzero,notnull,default:false"` // Is this attachment currently cached by our instance?
}
// File refers to the metadata for the whole file
type File struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
}
// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
type Thumbnail struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
URL string `bun:",nullzero"` // What is the URL of the thumbnail on the local server
RemoteURL string `bun:",nullzero"` // What is the remote URL of the thumbnail (empty for local media)
}
// ProcessingStatus refers to how far along in the processing stage the attachment is.
type ProcessingStatus int
// FileType refers to the file
// type of the media attaachment.
type FileType int
// FileMeta describes metadata about the actual contents of the file.
type FileMeta struct {
Original Original `bun:"embed:original_"`
Small Small `bun:"embed:small_"`
Focus Focus `bun:"embed:focus_"`
}
// Small can be used for a thumbnail of any media type
type Small struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
}
// Original can be used for original metadata for any media type
type Original struct {
Width int // width in pixels
Height int // height in pixels
Size int // size in pixels (width * height)
Aspect float32 // aspect ratio (width / height)
Duration *float32 // video-specific: duration of the video in seconds
Framerate *float32 // video-specific: fps
Bitrate *uint64 // video-specific: bitrate
}
// Focus describes the 'center' of the image for display purposes.
// X and Y should each be between -1 and 1
type Focus struct {
X float32
Y float32
}
+82
View File
@@ -237,6 +237,88 @@ func convertEnums[OldType ~string, NewType ~int16](
return nil
}
func addColumn(ctx context.Context, db bun.IDB, model any, fieldName string) error {
rtype := reflect.TypeOf(model)
// Get bun field information for this field on model.
field, table, err := getModelField(db, rtype, fieldName)
if err != nil {
return gtserror.Newf("error getting bun field %s.%s: %w", rtype, field, err)
}
// Generate bun column definition for model field.
colDef, err := getBunColumnDef(db, rtype, fieldName)
if err != nil {
return gtserror.Newf("error getting bun column definition for %T.%s: %w", rtype, fieldName, err)
}
log.Infof(ctx, "adding column '%s.%s'", table.Name, field.Name)
// Add column to database.
_, err = db.NewAddColumn().
Table(table.Name).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return gtserror.Newf("error adding column '%s.%s': %w", table.Name, field.Name, err)
}
return nil
}
func dropColumn(ctx context.Context, db bun.IDB, model any, fieldName string) error {
rtype := reflect.TypeOf(model)
// Get bun field information for this field on model.
field, table, err := getModelField(db, rtype, fieldName)
if err != nil {
return gtserror.Newf("error getting bun field %s.%s: %w", rtype, field, err)
}
log.Infof(ctx, "dropping column '%s.%s'", table.Name, field.Name)
// Attempt to drop this column.
_, err = db.NewDropColumn().
Table(table.Name).
Column(field.Name).
Exec(ctx)
if err != nil {
return gtserror.Newf("error dropping column '%s.%s': %w", table.Name, field.Name, err)
}
return nil
}
func createIndex(ctx context.Context, db bun.IDB, indexName, tableName, colExpr string, colArgs ...any) error {
log.Infof(ctx, "creating index '%s' on '%s'", indexName, tableName)
// Attempt to create this index.
_, err := db.NewCreateIndex().
Table(tableName).
Index(indexName).
ColumnExpr(colExpr, colArgs...).
Exec(ctx)
if err != nil {
return gtserror.Newf("error creating index '%s': %w", indexName, err)
}
return nil
}
func dropIndex(ctx context.Context, db bun.IDB, indexName string) error {
log.Infof(ctx, "dropping index '%s'", indexName)
// Attempt to drop this index.
_, err := db.NewDropIndex().
Index(indexName).
Exec(ctx)
if err != nil {
return gtserror.Newf("error dropping index '%s': %w", indexName, err)
}
return nil
}
// getBunColumnDef generates a column definition string for the SQL table represented by
// Go type, with the SQL column represented by the given Go field name. This ensures when
// adding a new column for table by migration that it will end up as bun would create it.
+3 -4
View File
@@ -19,7 +19,6 @@ package db
import (
"context"
"time"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
@@ -50,11 +49,11 @@ type Emoji interface {
// GetEmojis fetches all emojis with IDs less than 'maxID', up to a maximum of 'limit' emojis.
GetEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error)
// GetRemoteEmojis fetches all remote emojis with IDs less than 'maxID', up to a maximum of 'limit' emojis.
// GetRemoteEmojis fetches emojis with a non-empty domain, with given paging parameters.
GetRemoteEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error)
// GetCachedEmojisOlderThan fetches all cached remote emojis with 'updated_at' greater than 'olderThan', up to a maximum of 'limit' emojis.
GetCachedEmojisOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.Emoji, error)
// GetCachedEmojis fetches cached emojis with a non-empty domain, with given paging parameters.
GetCachedEmojis(ctx context.Context, page *paging.Page) ([]*gtsmodel.Emoji, error)
// GetEmojisBy gets emojis based on given parameters. Useful for admin actions.
GetEmojisBy(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, error)
+8 -8
View File
@@ -19,7 +19,6 @@ package db
import (
"context"
"time"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
@@ -42,14 +41,15 @@ type Media interface {
// DeleteAttachment deletes the attachment with given ID from the database.
DeleteAttachment(ctx context.Context, id string) error
// GetAttachments fetches media attachments up to a given max ID, and at most limit.
// AccountID is optional and can be provided to specify only attachments from given account.
GetAttachments(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.MediaAttachment, error)
// GetAttachments fetches media attachments, with given paging parameters.
GetAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error)
// GetRemoteAttachments fetches media attachments with a non-empty domain, up to a given max ID, and at most limit.
// GetAttachmentsByAccountID fetches media attachments by account ID, with given paging parameters.
GetAttachmentsByAccountID(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.MediaAttachment, error)
// GetRemoteAttachments fetches media attachments with a non-empty domain, with given paging parameters.
GetRemoteAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error)
// GetCachedAttachmentsOlderThan gets limit n remote attachments (including avatars and headers) older than
// the given time. These will be returned in order of attachment.created_at descending (i.e. newest to oldest).
GetCachedAttachmentsOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, error)
// GetCachedAttachments fetches cached media attachments with a non-empty domain, with given paging parameters.
GetCachedAttachments(ctx context.Context, page *paging.Page) ([]*gtsmodel.MediaAttachment, error)
}
+58 -66
View File
@@ -806,13 +806,23 @@ func (d *Dereferencer) enrichAccount(
latestAcc.FetchedAt = now
latestAcc.UpdatedAt = now
// Check whether there's any limits in
// place for this domain / subdomain.
// Check if there's any limits in place for (sub)domain.
limit, err := d.state.DB.MatchDomainLimit(ctx, uri.Host)
if err != nil {
return nil, nil, gtserror.Newf("error matching domain limit: %w", err)
}
rejectMedia := limit.MediaReject()
// If domain media is limited, set reject reason,
// this gets passed to media fetching functions
// and prevents download of attached account media.
var rejectReason *gtsmodel.MediaErrorDetails
if limit.MediaReject() {
rejectReason = new(gtsmodel.MediaErrorDetails)
*rejectReason = gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Domain,
)
}
// Ensure the account's avatar media
// is populated (if appropriate), passing
@@ -823,7 +833,7 @@ func (d *Dereferencer) enrichAccount(
account,
latestAcc,
apubAcc,
rejectMedia,
rejectReason,
); err != nil {
log.Errorf(ctx, "error fetching remote avatar for account %s: %v", uri, err)
}
@@ -837,7 +847,7 @@ func (d *Dereferencer) enrichAccount(
account,
latestAcc,
apubAcc,
rejectMedia,
rejectReason,
); err != nil {
log.Errorf(ctx, "error fetching remote header for account %s: %v", uri, err)
}
@@ -848,7 +858,7 @@ func (d *Dereferencer) enrichAccount(
ctx,
account,
latestAcc,
rejectMedia,
rejectReason,
); err != nil {
log.Errorf(ctx, "error fetching remote emojis for account %s: %v", uri, err)
}
@@ -887,7 +897,7 @@ func (d *Dereferencer) fetchAccountAvatar(
existingAcc *gtsmodel.Account,
latestAcc *gtsmodel.Account,
apubAcc ap.Accountable,
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) error {
latestAvatarURL := latestAcc.AvatarRemoteURL
if latestAvatarURL == "" {
@@ -896,18 +906,19 @@ func (d *Dereferencer) fetchAccountAvatar(
return nil
}
// Check for image description of avatar.
// Check for image description of account header avatar.
avatarDescription := ap.ExtractIconDescription(apubAcc)
// If account has a avatar set, and the avatar media
// has the same URL as the latest up-to-date URL,
// just ensure we have it cached (if appropriate).
if existingAcc.AvatarSet() && existingAcc.AvatarRemoteURL == latestAvatarURL {
if existingAcc.AvatarSet() &&
existingAcc.AvatarRemoteURL == latestAvatarURL {
// Avatar URL from existing account is up to date,
// so attachment ID will be up to date as well.
//
// Get this avatar media attachment from db.
// Get this avatar media attachment from database.
existing, err := d.state.DB.GetAttachmentByID(ctx,
existingAcc.AvatarMediaAttachmentID,
)
@@ -916,15 +927,12 @@ func (d *Dereferencer) fetchAccountAvatar(
}
if existing != nil {
// Prepare to update info if necessary.
var info media.AdditionalMediaInfo
// If set, pass rejectMedia flag to
// derefencer to skip downloading.
if rejectMedia {
info.RejectMedia = &rejectMedia
}
// Pass reject reason ptr, which
// will skip downloading if set.
info.RejectReason = rejectReason
// If description has changed,
// ensure this gets updated.
@@ -962,28 +970,22 @@ func (d *Dereferencer) fetchAccountAvatar(
return nil
}
// If existing was nil, then the avatar
// attachment got removed for some reason,
// so fall through to getting it from
// scratch amd setting a new ID, but log
// this as it's a bit strange.
log.Info(ctx,
"avatar %s was not found in db, refetching it from scratch",
// If existing was nil, then the attachment got removed for some reason,
// fall to fetching from scratch with a new ID, but log as it is strange.
log.Warnf(ctx, "%s not found in db, refetching from scratch",
existingAcc.AvatarMediaAttachmentID,
)
}
// Prepare media info.
// Prepare additional media info.
info := media.AdditionalMediaInfo{
Avatar: util.Ptr(true),
RemoteURL: &latestAvatarURL,
Description: &avatarDescription,
}
// If set, pass rejectMedia flag to
// derefencer to skip downloading.
if rejectMedia {
info.RejectMedia = &rejectMedia
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
}
// Fetch newly changed avatar.
@@ -997,16 +999,15 @@ func (d *Dereferencer) fetchAccountAvatar(
switch {
case err == nil:
// No problem,
// loaded it fine.
// loaded fine.
case attachment == nil:
// Fatal error occurred during
// loading, can't do anything with this.
return gtserror.Newf("error loading attachment %s: %w", latestAvatarURL, err)
// Fatal error during loading, can't do anything further.
return gtserror.Newf("error loading attachment %s: %w",
latestAvatarURL, err)
default:
// Non-fatal error occurred
// during loading, still use it.
// Non-fatal error during loading, can still use it.
log.Warnf(ctx, "partially loaded attachment: %v", err)
}
@@ -1024,7 +1025,7 @@ func (d *Dereferencer) fetchAccountHeader(
existingAcc *gtsmodel.Account,
latestAcc *gtsmodel.Account,
apubAcc ap.Accountable,
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) error {
latestHeaderURL := latestAcc.HeaderRemoteURL
if latestHeaderURL == "" {
@@ -1033,18 +1034,19 @@ func (d *Dereferencer) fetchAccountHeader(
return nil
}
// Check for image description of header.
// Check for image description of account header image.
headerDescription := ap.ExtractImageDescription(apubAcc)
// If account has a header set, and the header media
// has the same URL as the latest up-to-date URL,
// just ensure we have it cached (if appropriate).
if existingAcc.HeaderSet() && existingAcc.HeaderRemoteURL == latestHeaderURL {
if existingAcc.HeaderSet() &&
existingAcc.HeaderRemoteURL == latestHeaderURL {
// Header URL from existing account is up to date,
// so attachment ID will be up to date as well.
//
// Get this header media attachment from db.
// Get this header media attachment from database.
existing, err := d.state.DB.GetAttachmentByID(ctx,
existingAcc.HeaderMediaAttachmentID,
)
@@ -1053,15 +1055,12 @@ func (d *Dereferencer) fetchAccountHeader(
}
if existing != nil {
// Prepare to update info if necessary.
var info media.AdditionalMediaInfo
// If set, pass rejectMedia flag to
// derefencer to skip downloading.
if rejectMedia {
info.RejectMedia = &rejectMedia
}
// Pass reject reason ptr, which
// will skip downloading if set.
info.RejectReason = rejectReason
// If description has changed,
// ensure this gets updated.
@@ -1099,28 +1098,22 @@ func (d *Dereferencer) fetchAccountHeader(
return nil
}
// If existing was nil, then the header
// attachment got removed for some reason,
// so fall through to getting it from
// scratch amd setting a new ID, but log
// this as it's a bit strange.
log.Info(ctx,
"header %s was not found in db, refetching it from scratch",
// If existing was nil, then the attachment got removed for some reason,
// fall to fetching from scratch with a new ID, but log as it is strange.
log.Warnf(ctx, "%s not found in db, refetching from scratch",
existingAcc.HeaderMediaAttachmentID,
)
}
// Prepare media info.
// Prepare additional media info.
info := media.AdditionalMediaInfo{
Header: util.Ptr(true),
RemoteURL: &latestHeaderURL,
Description: &headerDescription,
}
// If set, pass rejectMedia flag to
// derefencer to skip downloading.
if rejectMedia {
info.RejectMedia = &rejectMedia
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
}
// Fetch newly changed header.
@@ -1134,16 +1127,15 @@ func (d *Dereferencer) fetchAccountHeader(
switch {
case err == nil:
// No problem,
// loaded it fine.
// loaded fine.
case attachment == nil:
// Fatal error occurred during
// loading, can't do anything with this.
return gtserror.Newf("error loading attachment %s: %w", latestHeaderURL, err)
// Fatal error during loading, can't do anything further.
return gtserror.Newf("error loading attachment %s: %w",
latestHeaderURL, err)
default:
// Non-fatal error occurred
// during loading, still use it.
// Non-fatal error during loading, can still use it.
log.Warnf(ctx, "partially loaded attachment: %v", err)
}
@@ -1159,13 +1151,13 @@ func (d *Dereferencer) fetchAccountEmojis(
ctx context.Context,
existing *gtsmodel.Account,
account *gtsmodel.Account,
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) error {
// Fetch the updated emojis for the account.
emojis, changed, err := d.fetchEmojis(ctx,
existing.Emojis,
account.Emojis,
rejectMedia, // rejectMedia
rejectReason,
)
if err != nil {
return gtserror.Newf("error fetching emojis: %w", err)
@@ -511,10 +511,15 @@ func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithAvatarDescription
remotePerson,
nil,
)
suite.NoError(err)
suite.NotNil(apAcc)
suite.Equal(updatedAcc.AvatarMediaAttachment.Description, description)
// our account media fetches are
// async, so wait until updated.
testrig.WaitFor(func() bool {
media, _ := suite.state.DB.GetAttachmentByID(ctx, updatedAcc.AvatarMediaAttachmentID)
return media != nil && media.Description == description
})
}
func TestAccountTestSuite(t *testing.T) {
+12 -19
View File
@@ -252,7 +252,7 @@ func (d *Dereferencer) RecacheEmoji(
return emoji, nil
}
if *emoji.Cached {
if emoji.Cached() {
// Already cached.
return emoji, nil
}
@@ -279,7 +279,7 @@ func (d *Dereferencer) RecacheEmoji(
return nil, nil, gtserror.Newf("error fetching emoji from db: %w", err)
}
if emoji != nil && *emoji.Cached {
if emoji != nil && emoji.Cached() {
// This was *just* cached.
return nil, emoji, nil
}
@@ -414,7 +414,7 @@ func (d *Dereferencer) fetchEmojis(
ctx context.Context,
existing []*gtsmodel.Emoji,
emojis []*gtsmodel.Emoji, // newly dereferenced
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) (
[]*gtsmodel.Emoji,
bool, // any changes?
@@ -423,13 +423,6 @@ func (d *Dereferencer) fetchEmojis(
// Track any changes.
changed := false
// If we're rejecting media from this
// domain, set this once outside the loop.
var rejectMediaPtr *bool
if rejectMedia {
rejectMediaPtr = &rejectMedia
}
for i, placeholder := range emojis {
// Look for an existing emoji with shortcode + domain.
existing, ok := getEmojiByShortcodeDomain(existing,
@@ -447,12 +440,14 @@ func (d *Dereferencer) fetchEmojis(
URI: &placeholder.URI,
ImageRemoteURL: &placeholder.ImageRemoteURL,
ImageStaticRemoteURL: &placeholder.ImageStaticRemoteURL,
// Pass rejectMedia ptr to derefencer
// to skip downloading if necessary.
RejectMedia: rejectMediaPtr,
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
}
// Ensure that the existing emoji model is up-to-date and cached.
// Ensure that the existing emoji
// model is up-to-date and cached.
existing, err := d.RefreshEmoji(
ctx,
existing,
@@ -483,12 +478,10 @@ func (d *Dereferencer) fetchEmojis(
URI: &placeholder.URI,
ImageRemoteURL: &placeholder.ImageRemoteURL,
ImageStaticRemoteURL: &placeholder.ImageStaticRemoteURL,
}
// If set, pass rejectMedia flag to
// derefencer to skip downloading.
if rejectMedia {
info.RejectMedia = &rejectMedia
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
}
// Fetch this newly added emoji,
+9 -2
View File
@@ -142,8 +142,15 @@ func (d *Dereferencer) RefreshMedia(
force = true
}
// Check if needs updating.
if *attach.Cached && !force {
switch {
case force:
// Unset any previous error
// to force a dereference.
attach.Error = 0
case attach.Cached():
// Return early, is already
// cached and no force flag.
return attach, nil
}
+26 -21
View File
@@ -585,13 +585,23 @@ func (d *Dereferencer) enrichStatus(
return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err)
}
// Check whether there's any limits in
// place for this domain / subdomain.
// Check if there's any limits in place for (sub)domain.
limit, err := d.state.DB.MatchDomainLimit(ctx, uri.Host)
if err != nil {
return nil, nil, gtserror.Newf("error matching domain limit: %w", err)
}
rejectMedia := limit.MediaReject()
// If domain media is limited, set reject reason,
// this gets passed to media fetching functions
// and prevents download of attached status media.
var rejectReason *gtsmodel.MediaErrorDetails
if limit.MediaReject() {
rejectReason = new(gtsmodel.MediaErrorDetails)
*rejectReason = gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Domain,
)
}
// Populate media attachments associated with status,
// passing in existing status to reuse old where possible
@@ -600,7 +610,7 @@ func (d *Dereferencer) enrichStatus(
requestUser,
status,
latestStatus,
rejectMedia,
rejectReason,
)
if err != nil {
return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err)
@@ -612,7 +622,7 @@ func (d *Dereferencer) enrichStatus(
emojiChanged, err := d.fetchStatusEmojis(ctx,
status,
latestStatus,
rejectMedia,
rejectReason,
)
if err != nil {
return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err)
@@ -821,32 +831,24 @@ func (d *Dereferencer) fetchStatusAttachments(
requestUser string,
existing *gtsmodel.Status,
status *gtsmodel.Status,
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) (
changed bool,
err error,
) {
// Allocate new slice to take the yet-to-be fetched attachment IDs.
status.AttachmentIDs = make([]string, len(status.Attachments))
// If we're rejecting media from this
// domain, set this once outside the loop.
var rejectMediaPtr *bool
if rejectMedia {
rejectMediaPtr = &rejectMedia
}
for i := range status.Attachments {
placeholder := status.Attachments[i]
// Look for existing media attachment with remote URL first.
existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL)
if ok && existing.ID != "" {
info := media.AdditionalMediaInfo{
// Pass rejectMedia ptr to derefencer
// to skip downloading if necessary.
RejectMedia: rejectMediaPtr,
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
}
// Look for any difference in stored media description.
@@ -900,7 +902,10 @@ func (d *Dereferencer) fetchStatusAttachments(
Blurhash: &placeholder.Blurhash,
FocusX: &placeholder.FileMeta.Focus.X,
FocusY: &placeholder.FileMeta.Focus.Y,
RejectMedia: &rejectMedia,
// Pass reject reason ptr, which
// will skip downloading if set.
RejectReason: rejectReason,
},
false, // async
)
@@ -940,7 +945,7 @@ func (d *Dereferencer) fetchStatusEmojis(
ctx context.Context,
existing *gtsmodel.Status,
status *gtsmodel.Status,
rejectMedia bool,
rejectReason *gtsmodel.MediaErrorDetails, // optional reason to reject media with
) (
changed bool,
err error,
@@ -950,7 +955,7 @@ func (d *Dereferencer) fetchStatusEmojis(
emojis, changed, err := d.fetchEmojis(ctx,
existing.Emojis,
status.Emojis,
rejectMedia,
rejectReason,
)
if err != nil {
return changed, gtserror.Newf("error fetching emojis: %w", err)
+9 -2
View File
@@ -164,8 +164,15 @@ func (l *keyedList[T]) put(key string, value T) {
func (l *keyedList[T]) delete(key string) {
for i, kv := range *l {
if kv.k == key {
copy((*l)[:i], (*l)[i+1:])
(*l) = (*l)[:len(*l)-1]
if len := len(*l); len > 1 {
// Reslice and clear elem.
copy((*l)[:i], (*l)[i+1:])
clear((*l)[len-1:])
(*l) = (*l)[:len-1]
} else if cap(*l) > 64 {
// Drop slice.
(*l) = nil
}
return
}
}
+23 -10
View File
@@ -33,18 +33,30 @@ func Newf(msgf string, args ...any) error {
return newfAt(3, msgf, args...)
}
// NewfAt returns a new error with message with given
// calldepth+1. Provide calldepth 2 to prepend only the
// name of the current containing function, 3 to prepend
// the name of the function containing *that* function,
// and so on...
//
// This function is just exposed for if you want to
// create an error wrapping function that maintains
// the function prefix of *your wrapper's caller*.
// In all other cases simply use New().
func NewAt(calldepth int, msg string) error {
return newAt(calldepth+1, msg)
}
// NewfAt returns a new formatted error with the given
// calldepth+1, useful when you want to wrap an error
// from within an anonymous function or utility function,
// but preserve the name in the error of the wrapping
// function that did the calling.
// calldepth+1. Provide calldepth 2 to prepend only the
// name of the current containing function, 3 to prepend
// the name of the function containing *that* function,
// and so on...
//
// Provide calldepth 2 to prepend only the name of the
// current containing function, 3 to prepend the name
// of the function containing *that* function, and so on.
//
// This function is just exposed for dry-dick optimization
// purposes. Most callers should just call Newf instead.
// This function is just exposed for if you want to
// create an error wrapping function that maintains
// the function prefix of *your wrapper's caller*.
// In all other cases simply use Newf().
func NewfAt(calldepth int, msgf string, args ...any) error {
return newfAt(calldepth+1, msgf, args...)
}
@@ -54,6 +66,7 @@ func NewfAt(calldepth int, msgf string, args ...any) error {
// will also wrap the returned error using WithStatusCode() and
// will include the caller function name as a prefix.
func NewFromResponse(rsp *http.Response) error {
// Build error with message without
// using "fmt", as chances are this will
// be used in a hot code path and we
+40 -21
View File
@@ -22,27 +22,27 @@ import "time"
// Emoji represents a custom emoji that's been uploaded
// through the admin UI or downloaded from a remote instance.
type Emoji struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain.
Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis.
ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis.
ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system.
ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system
ImageContentType string `bun:",notnull"` // MIME content type of the emoji image
ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image.
ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes.
ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes.
Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown?
URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234'
VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker?
Category *EmojiCategory `bun:"rel:belongs-to"` // In which emoji category is this emoji visible?
CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to.
Cached *bool `bun:",nullzero,notnull,default:false"` // whether emoji is cached in locally in gotosocial storage.
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain.
Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis.
ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis.
ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system.
ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system
ImageContentType string `bun:",notnull"` // MIME content type of the emoji image
ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image.
ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes.
ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes.
Error MediaErrorDetails `bun:",notnull,default:0"` // Details about any error encountered downloading file
Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown?
URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234'
VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker?
Category *EmojiCategory `bun:"rel:belongs-to"` // In which emoji category is this emoji visible?
CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to.
}
// IsLocal returns true if the emoji is
@@ -56,3 +56,22 @@ func (e *Emoji) IsLocal() bool {
func (e *Emoji) ShortcodeDomain() string {
return e.Shortcode + "@" + e.Domain
}
// Cached returns whether Emoji is cached locally.
func (e *Emoji) Cached() bool {
return e.ImagePath != "" &&
e.ImageStaticPath != ""
}
// Stub will reset all non-essential emoji
// fields, leaving it in the bare uncached state.
func (e *Emoji) Stub() {
e.ImageStaticContentType = ""
e.ImageStaticFileSize = 0
e.ImageStaticPath = ""
e.ImageStaticURL = ""
e.ImageContentType = ""
e.ImageFileSize = 0
e.ImagePath = ""
e.ImageURL = ""
}
+44 -29
View File
@@ -24,23 +24,22 @@ import (
// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
// somewhere in storage and that can be retrieved and served by the router.
type MediaAttachment struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
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:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown)
FileMeta FileMeta `bun:",embed:,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)
ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment
Processing ProcessingStatus `bun:",notnull,default:2"` // What is the processing status of this attachment
File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file
Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file.
Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar?
Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header?
Cached *bool `bun:",nullzero,notnull,default:false"` // Is this attachment currently cached by our instance?
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
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:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown)
Error MediaErrorDetails `bun:",notnull,default:0"` // Details about any error encountered downloading file
FileMeta FileMeta `bun:",embed:,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)
ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment
File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file
Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file.
Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar?
Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header?
}
// IsLocal returns whether media attachment is local.
@@ -53,13 +52,37 @@ func (m *MediaAttachment) IsRemote() bool {
return m.RemoteURL != ""
}
// File refers to the metadata for the whole file
// Cached returns whether MediaAttachment is cached locally.
func (m *MediaAttachment) Cached() bool {
return m.File.Cached() && m.Thumbnail.Cached()
}
// Stub will reset all non-essential attachment
// fields, leaving it in the bare never-downloaded state.
func (m *MediaAttachment) Stub() {
// we specifically don't stub
// out filemeta or URLs, as it
// can be useful if it's going
// to be later recached.
m.File.ContentType = ""
m.File.FileSize = 0
m.File.Path = ""
m.Thumbnail.FileSize = 0
m.Thumbnail.ContentType = ""
m.Thumbnail.Path = ""
}
// File refers to the metadata for the whole file.
type File struct {
Path string `bun:",notnull"` // Path of the file in storage.
ContentType string `bun:",notnull"` // MIME content type of the file.
FileSize int `bun:",notnull"` // File size in bytes
}
// Cached returns whether this File is cached locally.
func (f File) Cached() bool { return f.Path != "" }
// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
type Thumbnail struct {
Path string `bun:",notnull"` // Path of the file in storage.
@@ -69,20 +92,12 @@ type Thumbnail struct {
RemoteURL string `bun:",nullzero"` // What is the remote URL of the thumbnail (empty for local media)
}
// ProcessingStatus refers to how far along in the processing stage the attachment is.
type ProcessingStatus int
// MediaAttachment processing states.
const (
ProcessingStatusReceived ProcessingStatus = 0 // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet.
ProcessingStatusProcessing ProcessingStatus = 1 // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not.
ProcessingStatusProcessed ProcessingStatus = 2 // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served.
ProcessingStatusError ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.
)
// Cached returns whether this Thumbnail is cached locally.
func (t Thumbnail) Cached() bool { return t.Path != "" }
// FileType refers to the file
// type of the media attaachment.
type FileType int
type FileType enumType
const (
// MediaAttachment file types.
+183
View File
@@ -0,0 +1,183 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"net/http"
"strconv"
)
// MediaErrorDetails stores basic error details about
// why a piece of media may not have been downloaded.
// It contains a 16bit MediaErrorType, and the remaining
// 16bits may contain optional extra error details.
type MediaErrorDetails uint32
// MediaErrorType describes a broad error type for why
// one or more media files may not have been downloaded.
type MediaErrorType uint16
const (
// MediaErrorTypeNone: no error returned.
MediaErrorTypeNone MediaErrorType = 0
// MediaErrorTypePolicy: file(s) not downloaded due to configured policy.
MediaErrorTypePolicy MediaErrorType = 1
MediaErrorTypePolicy_Size uint16 = 1 // nolint:revive
MediaErrorTypePolicy_Domain uint16 = 2 // nolint:revive
// MediaErrorTypeInterrupt: file(s) not downloaded due to interrupt (i.e. context errors).
MediaErrorTypeInterrupt MediaErrorType = 2
// MediaErrorTypeHTTP: file(s) not downloaded due to HTTP response error.
// (the remaining 16bits of MediaErrorDetails store status code response)
MediaErrorTypeHTTP MediaErrorType = 3
// MediaErrorTypeNetwork: file(s) not downloaded due to network issue.
MediaErrorTypeNetwork MediaErrorType = 4
MediaErrorTypeNetwork_Timeout uint16 = 1 // nolint:revive
MediaErrorTypeNetwork_DNS uint16 = 2 // nolint:revive
// MediaErrorTypeCodec: file(s) not downloaded due to a codec issue.
MediaErrorTypeCodec MediaErrorType = 5
MediaErrorTypeCodec_Unsupported uint16 = 1 // nolint:revive
// MediaErrorTypeUnknown: file(s) not downloaded due to unclassified error.
MediaErrorTypeUnknown MediaErrorType = 6
)
// NewMediaErrorDetails returns a new MediaErrorDetails encapsulating MediaErrorType and details (if any).
func NewMediaErrorDetails(errType MediaErrorType, details uint16) MediaErrorDetails {
var d MediaErrorDetails
d.Set(errType, details)
return d
}
// Set will set the receiving MediaErrorDetails with MediaErrorType and extra details (if any).
func (d *MediaErrorDetails) Set(errType MediaErrorType, details uint16) {
(*d) = MediaErrorDetails(packu16s(uint16(errType), details))
}
// Type returns embedded MediaErrorType within details.
func (d MediaErrorDetails) Type() MediaErrorType {
const bits = 16
return MediaErrorType(uint16(d >> bits)) // nolint:gosec
}
// Details returns extra details related to Type(), if any are set.
func (d MediaErrorDetails) Details() uint16 {
const bits = 16
const mask = (1 << bits) - 1
return uint16(d & mask) // nolint:gosec
}
// SupportsRetry returns whether error supports a re-attempt
// to cache the media, i.e. due to it likely being transient.
func (d MediaErrorDetails) SupportsRetry() bool {
switch d.Type() {
// Either no error was encountered and it was
// later uncached, or the original fetch was
// interrupted by cancelled request etc.
case MediaErrorTypeNone,
MediaErrorTypeInterrupt:
return true
// All policy and media codec /
// processing errors are permanent.
case MediaErrorTypePolicy,
MediaErrorTypeCodec:
return false
// On timeout errors we can retry, others
// are more likely to be permanent.
case MediaErrorTypeNetwork:
return d.Details() == MediaErrorTypeNetwork_Timeout
// HTTP response code errors
// can be handled granularly
// depending on situation.
case MediaErrorTypeHTTP:
switch code := d.Details(); {
// 400-403 type errors (e.g. auth, forbidden, bad request)
// *can* be transient e.g. due to bugs. Others in the 4xx
// range are generally more permanent (e.g. not found).
case code >= 404:
return false
// More likely to be
// a temporary error.
case code >= 500:
return true
// All else
// we deny.
default:
return false
}
}
// Default to yes.
return true
}
// String returns a frontend API (and log) string describing error details.
func (d MediaErrorDetails) String() string {
switch errType := d.Type(); errType {
case MediaErrorTypeNone:
return "none"
case MediaErrorTypePolicy:
switch d.Details() {
case MediaErrorTypePolicy_Size:
return "file size limit reached"
case MediaErrorTypePolicy_Domain:
return "domain media policy"
default:
return "configuration policy"
}
case MediaErrorTypeInterrupt:
return "connection interrupted"
case MediaErrorTypeHTTP:
status := int(d.Details())
return "http response (status code: " + strconv.Itoa(status) +
" " + http.StatusText(status) + ")"
case MediaErrorTypeNetwork:
switch d.Details() {
case MediaErrorTypeNetwork_Timeout:
return "network timeout"
default:
return "network error"
}
case MediaErrorTypeCodec:
switch d.Details() {
case MediaErrorTypeCodec_Unsupported:
return "unsupported media type"
default:
return "media processing error"
}
default:
return "unclassified"
}
}
func packu16s(u1, u2 uint16) uint32 {
const bits = 16
const mask = (1 << bits) - 1
return uint32(u1)<<bits | uint32(u2)&mask
}
+111
View File
@@ -0,0 +1,111 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel_test
import (
"testing"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"github.com/stretchr/testify/assert"
)
var mediaErrorDetailsTests = []struct {
p gtsmodel.MediaErrorDetails
u1 gtsmodel.MediaErrorType
u2 uint16
}{
{
p: 0,
u1: 0,
u2: 0,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeNone, 0),
u1: gtsmodel.MediaErrorTypeNone,
u2: 0,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeInterrupt, 0),
u1: gtsmodel.MediaErrorTypeInterrupt,
u2: 0,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypePolicy, gtsmodel.MediaErrorTypePolicy_Size),
u1: gtsmodel.MediaErrorTypePolicy,
u2: gtsmodel.MediaErrorTypePolicy_Size,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypePolicy, gtsmodel.MediaErrorTypePolicy_Domain),
u1: gtsmodel.MediaErrorTypePolicy,
u2: gtsmodel.MediaErrorTypePolicy_Domain,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeNetwork, gtsmodel.MediaErrorTypeNetwork_DNS),
u1: gtsmodel.MediaErrorTypeNetwork,
u2: gtsmodel.MediaErrorTypeNetwork_DNS,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeNetwork, gtsmodel.MediaErrorTypeNetwork_Timeout),
u1: gtsmodel.MediaErrorTypeNetwork,
u2: gtsmodel.MediaErrorTypeNetwork_Timeout,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeHTTP, 400),
u1: gtsmodel.MediaErrorTypeHTTP,
u2: 400,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeHTTP, 404),
u1: gtsmodel.MediaErrorTypeHTTP,
u2: 404,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeHTTP, 500),
u1: gtsmodel.MediaErrorTypeHTTP,
u2: 500,
},
{
p: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeCodec, gtsmodel.MediaErrorTypeCodec_Unsupported),
u1: gtsmodel.MediaErrorTypeCodec,
u2: gtsmodel.MediaErrorTypeCodec_Unsupported,
},
}
func TestMediaErrorDetailsPack(t *testing.T) {
for _, test := range mediaErrorDetailsTests {
d := gtsmodel.NewMediaErrorDetails(test.u1, test.u2)
u1, u2 := unpacku16s(uint32(d))
assert.Equal(t, u1, uint16(test.u1))
assert.Equal(t, u2, uint16(test.u2))
}
}
func TestMediaErrorDetailsUnpack(t *testing.T) {
for _, test := range mediaErrorDetailsTests {
assert.Equal(t, test.u1, test.p.Type())
assert.Equal(t, test.u2, test.p.Details())
}
}
func unpacku16s(u uint32) (u1, u2 uint16) {
const bits = 16
const mask = (1 << bits) - 1
u1 = uint16(u >> bits)
u2 = uint16(u & mask)
return
}
+10
View File
@@ -102,3 +102,13 @@ func TimeFromULID(id string) (time.Time, error) {
}
return ulid.Time(parsed.Time()), nil
}
// ZeroULIDForTime returns the zero-value ULID for given time.
func ZeroULIDForTime(t time.Time) string {
ts := ulid.Timestamp(t)
var ulid ulid.ULID
if err := ulid.SetTime(ts); err != nil {
panic(err)
}
return ulid.String()
}
+123
View File
@@ -0,0 +1,123 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package media
import (
"context"
"net"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"codeberg.org/gruf/go-errors/v2"
)
const codecDetails = gtsmodel.MediaErrorDetails(gtsmodel.MediaErrorTypeCodec)
// errWithDetails allows optionally wrapping an error,
// but largely propagating MediaErrorDetails via error return.
type errWithDetails struct {
error
details gtsmodel.MediaErrorDetails
}
// withDetails wraps an optional error with given MediaErrorDetails as error type.
func withDetails(err error, details gtsmodel.MediaErrorDetails) error {
return &errWithDetails{err, details}
}
func (err *errWithDetails) Error() string {
if err.error == nil {
// if no error was set, instead
// use stringified details given.
return err.details.String()
}
return err.error.Error()
}
func (err *errWithDetails) Unwrap() error {
return err.error
}
// isStubError returns whether determined gtsmodel.MediaErrorDetails
// was due to a "stubbing" type error, i.e. a sort of non-error.
func isStubError(details gtsmodel.MediaErrorDetails) bool {
return details.Type() == gtsmodel.MediaErrorTypePolicy ||
details.Details() == gtsmodel.MediaErrorTypeCodec_Unsupported
}
// toErrorDetails will convert given error to extracted MediaErrorDetails (if any).
func toErrorDetails(err error) gtsmodel.MediaErrorDetails {
if err == nil {
// No error was returned, no details.
return gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeNone,
0,
)
} else if withDetails := errors.AsV2[*errWithDetails](err); withDetails != nil {
// Return stored err details.
return withDetails.details
} else if errors.IsV2(err, context.Canceled, context.DeadlineExceeded) {
// Interrupt error due to context cancelled.
return gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeInterrupt,
0,
)
} else if details := extractNetworkErrorDetails(err); details != 0 {
// Return determined
// error details.
return details
}
// Any other type was unclassified error.
return gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeUnknown,
0,
)
}
// extractNetworkErrorDetails looks for and returns any network / http related details in error.
func extractNetworkErrorDetails(err error) gtsmodel.MediaErrorDetails {
if code := gtserror.StatusCode(err); code > 0 {
// An HTTP status code was set, indicating error
// due to HTTP response, extract and set details.
return gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeHTTP,
uint16(code), // nolint:gosec
)
} else if netErr := errors.AsV2[interface{ Timeout() bool }](err); netErr != nil {
var details uint16
// All "net{,/http}" package errors implement
// Timeout(), use this to set type and details.
if netErr.Timeout() {
details = gtsmodel.MediaErrorTypeNetwork_Timeout
} else if _, isDNS := netErr.(*net.DNSError); isDNS {
details = gtsmodel.MediaErrorTypeNetwork_DNS
}
return gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeNetwork,
details,
)
}
return 0
}
+19 -7
View File
@@ -69,7 +69,6 @@ func ffmpegClearMetadata(ctx context.Context, outpath, inpath string) error {
// ffmpegGenerateWebpThumb generates a thumbnail webp from input media of any type, useful for any media.
func ffmpegGenerateWebpThumb(ctx context.Context, inpath, outpath string, width, height int, pixfmt string) error {
// Generate thumb with ffmpeg.
return ffmpeg(ctx, inpath, outpath,
// Only log errors.
@@ -181,7 +180,8 @@ func ffmpeg(ctx context.Context, inpath string, outpath string, args ...string)
if err != nil {
return gtserror.Newf("error running: %w", err)
} else if rc != 0 {
return gtserror.Newf("non-zero return code %d (%s)", rc, stderr.B)
err := gtserror.Newf("non-zero return code %d (%s)", rc, stderr.B)
return withDetails(err, codecDetails)
}
return nil
}
@@ -256,7 +256,7 @@ func ffprobe(ctx context.Context, filepath string) (*result, error) {
// Convert raw result data.
res, err := result.Process()
if err != nil {
return nil, err
return nil, withDetails(err, codecDetails)
}
return res, nil
@@ -497,8 +497,17 @@ func (res *result) PixFmt() string {
// Process converts raw ffprobe result data into our more usable result{} type.
func (res *ffprobeResult) Process() (*result, error) {
if res.Error != nil {
return nil, res.Error
// Only return ffprobe error if it's NOT
// unsupported, otherwise we want other
// code paths to handle this file case.
if !res.Error.IsUnsupported() {
return nil, res.Error
}
// Return loggable format string indicating the issue.
return &result{format: "unsupported by ffprobe"}, nil
}
if res.Format == nil {
@@ -698,14 +707,17 @@ type ffprobeFormat struct {
BitRate string `json:"bit_rate"`
}
// ffprobeError is a representation of the JSON
// error details that ffprobe may return on attempted
// probing of media file for details. it has helper
// methods to allow it to implement error{}
type ffprobeError struct {
Code int `json:"code"`
String string `json:"string"`
}
func isUnsupportedTypeErr(err error) bool {
ffprobeErr, ok := err.(*ffprobeError)
return ok && ffprobeErr.Code == -1094995529
func (err *ffprobeError) IsUnsupported() bool {
return err.Code == -1094995529
}
func (err *ffprobeError) Error() string {
+51 -14
View File
@@ -112,14 +112,12 @@ func (m *Manager) CreateMedia(
// leaving out fields with values we don't know
// yet. These will be overwritten as we go.
attachment := &gtsmodel.MediaAttachment{
ID: id.NewULID(),
AccountID: accountID,
Type: gtsmodel.FileTypeUnknown,
Processing: gtsmodel.ProcessingStatusReceived,
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
CreatedAt: now,
ID: id.NewULID(),
AccountID: accountID,
Type: gtsmodel.FileTypeUnknown,
Avatar: util.Ptr(false),
Header: util.Ptr(false),
CreatedAt: now,
}
// Check if we were provided additional info
@@ -172,11 +170,24 @@ func (m *Manager) CacheMedia(
data DataFunc,
info AdditionalMediaInfo,
) *ProcessingMedia {
var stubErr error
if reason := info.RejectReason; reason != nil {
// If a predefined reject reason was provided,
// don't download and return early with error.
stubErr = &errWithDetails{details: *reason}
} else if details := media.Error; !details.SupportsRetry() {
// If failed to download due to an existing error,
// don't attempt redownload, return early with error.
err := gtserror.New("unretryable error: " + details.String())
stubErr = &errWithDetails{error: err, details: details}
}
return &ProcessingMedia{
media: media,
dataFn: data,
mgr: m,
stubOnly: util.PtrOrZero(info.RejectMedia),
media: media,
dataFn: data,
mgr: m,
err: stubErr,
}
}
@@ -344,13 +355,26 @@ func (m *Manager) CacheEmoji(
pathID = id
}
var stubErr error
if reason := info.RejectReason; reason != nil {
// If a predefined reject reason was provided,
// don't download and return early with error.
stubErr = &errWithDetails{details: *reason}
} else if details := emoji.Error; !details.SupportsRetry() {
// If failed to download due to an existing error,
// don't attempt redownload, return early with error.
err := gtserror.New("unretryable error: " + details.String())
stubErr = &errWithDetails{error: err, details: details}
}
return &ProcessingEmoji{
newPathID: pathID,
instAccID: instanceAcc.ID,
emoji: emoji,
dataFn: data,
mgr: m,
stubOnly: util.PtrOrZero(info.RejectMedia),
err: stubErr,
}, nil
}
@@ -374,6 +398,8 @@ func (m *Manager) createOrUpdateEmoji(
return nil, gtserror.Newf("error fetching instance account: %w", err)
}
var stubErr error
// Check if we have additional info to add to the emoji,
// and overwrite some of the emoji fields if so.
if info.URI != nil {
@@ -397,6 +423,16 @@ func (m *Manager) createOrUpdateEmoji(
if info.CategoryID != nil {
emoji.CategoryID = *info.CategoryID
}
if reason := info.RejectReason; reason != nil {
// If a predefined reject reason was provided,
// don't download and return early with error.
stubErr = &errWithDetails{details: *reason}
} else if details := emoji.Error; !details.SupportsRetry() {
// If failed to download due to an existing error,
// don't attempt redownload, return early with error.
err := gtserror.New("unretryable error: " + details.String())
stubErr = &errWithDetails{error: err, details: details}
}
// Put or update emoji in database.
if err := storeDB(ctx, emoji); err != nil {
@@ -409,7 +445,7 @@ func (m *Manager) createOrUpdateEmoji(
emoji: emoji,
dataFn: data,
mgr: m,
stubOnly: util.PtrOrZero(info.RejectMedia),
err: stubErr,
}
return processingEmoji, nil
@@ -417,6 +453,7 @@ func (m *Manager) createOrUpdateEmoji(
// extractEmojiPathID pulls the ID used in the final path segment of an emoji path (can be URL).
func extractEmojiPathID(path string) string {
// Look for '.' indicating file ext.
i := strings.LastIndexByte(path, '.')
if i == -1 {
+50 -18
View File
@@ -86,7 +86,8 @@ func (suite *ManagerTestSuite) TestEmojiProcess() {
func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
ctx := suite.T().Context()
// we're going to 'refresh' the remote 'yell' emoji by changing the image url to the pixellated gts logo
// we're going to 'refresh' the remote 'yell' emoji
// by changing the image url to the pixellated gts logo
originalEmoji := suite.testEmojis["yell"]
emojiToUpdate, err := suite.db.GetEmojiByID(ctx, originalEmoji.ID)
@@ -186,8 +187,19 @@ func (suite *ManagerTestSuite) TestEmojiProcessTooLarge() {
suite.NoError(err)
// do a blocking call to fetch the emoji
_, err = processing.Load(ctx)
suite.EqualError(err, "store: error draining data to tmp: reached read limit 630kiB")
emoji, err := processing.Load(ctx)
suite.NoError(err)
// now make sure the emoji is in the database
dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID)
suite.NoError(err)
suite.NotNil(dbEmoji)
// Emoji should have an appropriate error.
suite.Equal(gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Size,
), emoji.Error)
}
func (suite *ManagerTestSuite) TestEmojiWebpProcess() {
@@ -320,8 +332,20 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessTooLarge() {
suite.NotNil(processing)
// do a blocking call to fetch the attachment
_, err = processing.Load(ctx)
suite.EqualError(err, "store: error draining data to tmp: reached read limit 263kiB")
attachment, err := processing.Load(ctx)
suite.NoError(err)
// now make sure the attachment is in the database
dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID)
suite.NoError(err)
suite.NotNil(dbAttachment)
// Attachment should have type unknown and appropriate error.
suite.Equal(gtsmodel.FileTypeUnknown, dbAttachment.Type)
suite.Equal(gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Size,
), dbAttachment.Error)
}
func (suite *ManagerTestSuite) TestPDFProcess() {
@@ -368,16 +392,16 @@ func (suite *ManagerTestSuite) TestPDFProcess() {
suite.NoError(err)
suite.NotNil(dbAttachment)
// Attachment should have type unknown
// Attachment should have type unknown and appropriate error.
suite.Equal(gtsmodel.FileTypeUnknown, dbAttachment.Type)
suite.Equal(gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeCodec,
gtsmodel.MediaErrorTypeCodec_Unsupported,
), dbAttachment.Error)
// Nothing should be in storage for this attachment.
stored, err := suite.storage.Has(ctx, attachment.File.Path)
suite.NoError(err)
suite.False(stored)
stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path)
suite.NoError(err)
suite.False(stored)
// Nothing should be in storage for attachment.
suite.Empty(dbAttachment.Thumbnail.Path)
suite.Empty(dbAttachment.File.Path)
}
func (suite *ManagerTestSuite) TestSlothVineProcess() {
@@ -1075,7 +1099,10 @@ func (suite *ManagerTestSuite) TestUncacheRejectedMedia() {
attachment,
nil,
media.AdditionalMediaInfo{
RejectMedia: util.Ptr(true),
RejectReason: util.Ptr(gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Domain,
)),
},
)
@@ -1086,11 +1113,16 @@ func (suite *ManagerTestSuite) TestUncacheRejectedMedia() {
if err != nil {
suite.FailNow(err.Error())
}
suite.False(*attachment.Cached)
suite.False(attachment.Cached())
suite.Equal(gtsmodel.FileTypeUnknown, attachment.Type)
suite.Zero(attachment.File)
suite.Zero(attachment.FileMeta)
suite.Zero(attachment.Thumbnail)
suite.Empty(attachment.Thumbnail.Path)
suite.Empty(attachment.File.Path)
// The reject reason error should be stored.
suite.Equal(gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Domain,
), attachment.Error)
// Blurhash + description
// should be preserved.
+2 -1
View File
@@ -100,7 +100,8 @@ func terminateExif(outpath, inpath string, ext string) (err error) {
// Terminate EXIF data from 'inFile' -> 'outFile'.
err = terminator.TerminateInto(outFile, inFile, ext)
if err != nil {
return gtserror.Newf("error terminating exif data: %w", err)
err := gtserror.Newf("error terminating exif data: %w", err)
return withDetails(err, codecDetails)
}
return nil
+2 -1
View File
@@ -87,7 +87,8 @@ func probeJPEG(file fileReader) (*result, error) {
file,
))
if err != nil {
return nil, gtserror.Newf("error decoding file %s: %w", file.Name(), err)
err := gtserror.Newf("error decoding file %s: %w", file.Name(), err)
return nil, withDetails(err, codecDetails)
}
// Jump back to file start.
+55 -51
View File
@@ -26,7 +26,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/storage"
"code.superseriousbusiness.org/gotosocial/internal/uris"
"code.superseriousbusiness.org/gotosocial/internal/util"
"codeberg.org/gruf/go-errors/v2"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-runners"
)
@@ -34,41 +34,38 @@ import (
// ProcessingEmoji represents an emoji currently processing. It exposes
// various functions for retrieving data from the process.
type ProcessingEmoji struct {
// Processing emoji details.
// processing emoji details.
emoji *gtsmodel.Emoji
// Instance account ID, used to
// instance account ID, used to
// construct emoji storage path.
instAccID string
// New emoji path ID to
// new emoji path ID to
// use when being refreshed.
newPathID string
// Load-data function,
// load data function,
// returns media stream.
dataFn DataFunc
// done is set when process finishes
// with non ctx canceled type error
done bool
// proc helps synchronize only a
// singular running processing instance
proc runners.Processor
// error stores permanent
// error value when done
// error stores permanent value when done,
// or alternatively may store a preset stubError{}
// value with details to allow skipping processing.
err error
// mgr instance, for access to
// db / storage during processing
mgr *Manager
// true if this emoji should not
// be downloaded, ie., should be
// returned as placeholder only.
stubOnly bool
// done is set when process finishes
// with non ctx canceled type error
done bool
}
// Load blocks until the static and fullsize image has been processed, and then returns the completed emoji.
@@ -101,7 +98,6 @@ func (p *ProcessingEmoji) Placeholder() *gtsmodel.Emoji {
emoji.ID = p.emoji.ID
emoji.Shortcode = p.emoji.Shortcode
emoji.Domain = p.emoji.Domain
emoji.Cached = new(bool)
emoji.ImageRemoteURL = p.emoji.ImageRemoteURL
emoji.ImageStaticRemoteURL = p.emoji.ImageStaticRemoteURL
emoji.Disabled = p.emoji.Disabled
@@ -133,10 +129,11 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
done bool,
err error,
) {
err = p.proc.Process(func() error {
err = p.proc.Process(func() (err error) {
if done = p.done; done {
// Already proc'd.
return p.err
err = p.err
return
}
defer func() {
@@ -153,33 +150,36 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
// (i.e. no ctx canceled).
ctx = context.WithoutCancel(ctx)
// Store values.
p.done = done
p.err = err
// On error or stub, ensure
// no downloaded files remain.
if err != nil || p.stubOnly {
if err != nil {
p.cleanup(ctx)
}
// Check the extracted error details on emoji for
// stub type error. i.e. policy or media type issue.
if isStubError(p.emoji.Error) {
log.Warnf(ctx, "stubbed %s due to: %v", p.emoji.ImageRemoteURL, p.err)
err = nil // don't return stub errors
}
// Update with latest details, whatever happened.
e := p.mgr.state.DB.UpdateEmoji(ctx, p.emoji)
if e != nil {
log.Errorf(ctx, "error updating emoji in db: %v", e)
}
// Store values.
p.done = true
p.err = err
}
}()
// If we're only stubbing, skip
// calling store() to cache the emoji.
//
// Any files that may have been stored
// for it previously will be cleaned up
// by the deferred function above.
if p.stubOnly {
err = nil
return err
// If existing error details exists, check if supports retry.
if withDetails := errors.AsV2[*errWithDetails](p.err); //
withDetails != nil && !withDetails.details.SupportsRetry() {
err = p.err
return
}
// Attempt to store media and calculate
@@ -187,7 +187,7 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
//
// This will update p.emoji as it goes.
err = p.store(ctx)
return err
return
})
// Return a copy of emoji.
@@ -200,9 +200,17 @@ func (p *ProcessingEmoji) load(ctx context.Context) (
// and updates the underlying attachment fields as necessary. It will then stream
// bytes from p's reader directly into storage so that it can be retrieved later.
func (p *ProcessingEmoji) store(ctx context.Context) error {
// Load media from data func.
rc, err := p.dataFn(ctx)
if err != nil {
// If a network error, include these details.
if details := extractNetworkErrorDetails(err); //
details != 0 {
err = withDetails(err, details)
}
return gtserror.Newf("error executing data function: %w", err)
}
@@ -230,11 +238,8 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
// Pass input file through ffprobe to
// parse further metadata information.
result, err := probe(ctx, temppath)
if err != nil && !isUnsupportedTypeErr(err) {
if err != nil {
return gtserror.Newf("ffprobe error: %w", err)
} else if result == nil {
log.Warnf(ctx, "unsupported data type by ffprobe: %v", err)
return nil
}
var ext string
@@ -243,7 +248,10 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
// Get abstract file type, mimetype and ext from ffprobe data.
fileType, p.emoji.ImageContentType, ext = result.GetFileType()
if fileType != gtsmodel.FileTypeImage {
return gtserror.Newf("unsupported emoji filetype: %s (%s)", fileType, ext)
// NOTE: unlike regular media, unsupported file type is an error for emoji.
err := gtserror.Newf("unsupported emoji filetype: %s (%s)", fileType, ext)
return withDetails(err, codecDetails)
}
// Add file extension to path.
@@ -337,14 +345,17 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
"png",
)
// We can now consider this cached.
p.emoji.Cached = util.Ptr(true)
// Success! Unset previous
// error details for emoji.
p.emoji.Error = 0
return nil
}
// cleanup will remove any traces of processing emoji from storage,
// and perform any other necessary cleanup steps after failure.
//
// details of any error are extracted and can be accessed via p.emoji.Error.
func (p *ProcessingEmoji) cleanup(ctx context.Context) {
log.Debugf(ctx, "running cleanup of emoji %s", p.emoji.ID)
@@ -364,16 +375,9 @@ func (p *ProcessingEmoji) cleanup(ctx context.Context) {
}
}
// Unset processor-calculated fields.
p.emoji.ImageStaticContentType = ""
p.emoji.ImageStaticFileSize = 0
p.emoji.ImageStaticPath = ""
p.emoji.ImageStaticURL = ""
p.emoji.ImageContentType = ""
p.emoji.ImageFileSize = 0
p.emoji.ImagePath = ""
p.emoji.ImageURL = ""
// Unset fields.
p.emoji.Stub()
// Ensure marked as not cached.
p.emoji.Cached = util.Ptr(false)
// Extract any error details for db.
p.emoji.Error = toErrorDetails(p.err)
}
+61 -66
View File
@@ -21,8 +21,8 @@ import (
"context"
"os"
"codeberg.org/gruf/go-errors/v2"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-kv/v2"
"codeberg.org/gruf/go-runners"
"code.superseriousbusiness.org/gopkg/log"
@@ -38,34 +38,30 @@ import (
// currently being processed. It exposes functions
// for retrieving data from the process.
type ProcessingMedia struct {
// Processing media
// attachment details.
// processing media attach details.
media *gtsmodel.MediaAttachment
// Load-data function,
// load data function,
// returns media stream.
dataFn DataFunc
// done is set when process finishes
// with non ctx canceled type error
done bool
// proc helps synchronize only a
// singular running processing instance
proc runners.Processor
// error stores permanent
// error value when done
// error stores permanent value when done,
// or alternatively may store a preset stubError{}
// value with details to allow skipping processing.
err error
// mgr instance, for access to
// db / storage during processing
mgr *Manager
// true if this piece of media should
// not be downloaded, ie., should be
// stubbed as an Unknown type only
stubOnly bool
// done is set when process finishes
// with non ctx canceled type error
done bool
}
// MustLoad blocks until the thumbnail and fullsize image has been processed, and then returns the completed media.
@@ -100,10 +96,8 @@ func (p *ProcessingMedia) Placeholder() *gtsmodel.MediaAttachment {
media.StatusID = p.media.StatusID
media.ScheduledStatusID = p.media.ScheduledStatusID
media.Description = p.media.Description
media.Processing = p.media.Processing
media.Avatar = p.media.Avatar
media.Header = p.media.Header
media.Cached = new(bool)
media.RemoteURL = p.media.RemoteURL
media.Thumbnail.RemoteURL = p.media.Thumbnail.RemoteURL
media.Blurhash = p.media.Blurhash
@@ -127,10 +121,11 @@ func (p *ProcessingMedia) load(ctx context.Context) (
done bool,
err error,
) {
err = p.proc.Process(func() error {
err = p.proc.Process(func() (err error) {
if done = p.done; done {
// Already proc'd.
return p.err
err = p.err
return
}
defer func() {
@@ -147,33 +142,36 @@ func (p *ProcessingMedia) load(ctx context.Context) (
// (i.e. no ctx canceled).
ctx = context.WithoutCancel(ctx)
// On error, stub, or unknown media
// types, perform error cleanup.
if err != nil || p.stubOnly || p.media.Type == gtsmodel.FileTypeUnknown {
// Store values.
p.done = done
p.err = err
// If any error value is stored, including
// stubError (e.g. unknown type), do cleanup().
if p.err != nil {
p.cleanup(ctx)
}
// Check the extracted error details on media for
// stub type error. i.e. policy or media type issue.
if isStubError(p.media.Error) {
log.Warnf(ctx, "stubbed %s due to: %v", p.media.RemoteURL, p.err)
err = nil // don't return stub errors
}
// Update with latest details, whatever happened.
e := p.mgr.state.DB.UpdateAttachment(ctx, p.media)
if e != nil {
log.Errorf(ctx, "error updating media in db: %v", e)
}
// Store values.
p.done = true
p.err = err
}
}()
// If we're only stubbing, skip
// calling store() to cache the media.
//
// Any files that may have been stored
// for it previously will be cleaned up
// by the deferred function above.
if p.stubOnly {
err = nil
return err
// If existing error details exists, check if supports retry.
if withDetails := errors.AsV2[*errWithDetails](p.err); //
withDetails != nil && !withDetails.details.SupportsRetry() {
err = p.err
return
}
// Attempt to store media and calculate
@@ -181,7 +179,7 @@ func (p *ProcessingMedia) load(ctx context.Context) (
//
// This will update p.media as it goes.
err = p.store(ctx)
return err
return
})
// Return a copy of media attachment.
@@ -194,9 +192,17 @@ func (p *ProcessingMedia) load(ctx context.Context) (
// and updates the underlying attachment fields as necessary. It will then stream
// bytes from p's reader directly into storage so that it can be retrieved later.
func (p *ProcessingMedia) store(ctx context.Context) error {
// Load media from data func.
rc, err := p.dataFn(ctx)
if err != nil {
// If a network error, include these details.
if details := extractNetworkErrorDetails(err); //
details != 0 {
err = withDetails(err, details)
}
return gtserror.Newf("error executing data function: %w", err)
}
@@ -224,11 +230,8 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
// Pass input file through ffprobe to
// parse further metadata information.
result, err := probe(ctx, temppath)
if err != nil && !isUnsupportedTypeErr(err) {
if err != nil {
return gtserror.Newf("ffprobe error: %w", err)
} else if result == nil {
log.Warnf(ctx, "unsupported data type by ffprobe: %v", err)
return nil
}
var ext string
@@ -248,6 +251,15 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
// Set generic media type and mimetype from ffprobe format data.
p.media.Type, p.media.File.ContentType, ext = result.GetFileType()
if p.media.Type == gtsmodel.FileTypeUnknown {
// On unsupported return a stub error that doesn't
// get returned to the caller, but indicates details.
return withDetails(nil, gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypeCodec,
gtsmodel.MediaErrorTypeCodec_Unsupported,
))
}
// Add file extension to path.
newpath := temppath + "." + ext
@@ -273,13 +285,6 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
case gtsmodel.FileTypeAudio:
// NOTE: we do not clean audio file
// metadata, in order to keep tags.
default:
log.WarnKVs(ctx, kv.Fields{
{K: "format", V: result.format},
{K: "msg", V: "unsupported data type"},
}...)
return nil
}
if width > 0 && height > 0 {
@@ -387,11 +392,9 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
ext,
)
// We can now consider this cached.
p.media.Cached = util.Ptr(true)
// Finally set the attachment as finished processing.
p.media.Processing = gtsmodel.ProcessingStatusProcessed
// Success! Unset previous
// error details for media.
p.media.Error = 0
return nil
}
@@ -415,21 +418,13 @@ func (p *ProcessingMedia) cleanup(ctx context.Context) {
}
}
// Unset all processor-calculated media fields.
p.media.FileMeta.Original = gtsmodel.Original{}
p.media.FileMeta.Small = gtsmodel.Small{}
p.media.File.ContentType = ""
p.media.File.FileSize = 0
p.media.File.Path = ""
p.media.Thumbnail.FileSize = 0
p.media.Thumbnail.ContentType = ""
p.media.Thumbnail.Path = ""
p.media.Thumbnail.URL = ""
p.media.URL = ""
// Unset fields.
p.media.Stub()
// Also ensure marked as unknown and finished
// processing so gets inserted as placeholder URL.
p.media.Processing = gtsmodel.ProcessingStatusProcessed
// Extract any error details for db.
p.media.Error = toErrorDetails(p.err)
// Also ensure marked as unknown
// so gets inserted as placeholder URL.
p.media.Type = gtsmodel.FileTypeUnknown
p.media.Cached = util.Ptr(false)
}
+10 -5
View File
@@ -242,7 +242,8 @@ func generateNativeThumb(
_ = infile.Close()
if err != nil {
return "", gtserror.Newf("error decoding file %s: %w", inpath, err)
err := gtserror.Newf("error decoding file %s: %w", inpath, err)
return "", withDetails(err, codecDetails)
}
// Apply orientation BEFORE any resize,
@@ -284,7 +285,8 @@ func generateNativeThumb(
_ = outfile.Close()
if err != nil {
return "", gtserror.Newf("error encoding image: %w", err)
err := gtserror.Newf("error encoding image: %w", err)
return "", withDetails(err, codecDetails)
}
if needBlurhash {
@@ -301,7 +303,8 @@ func generateNativeThumb(
// Generate blurhash for the tiny thumbnail.
blurhash, err := blurhash.Encode(4, 3, tiny)
if err != nil {
return "", gtserror.Newf("error generating blurhash: %w", err)
err := gtserror.Newf("error generating blurhash: %w", err)
return "", withDetails(err, codecDetails)
}
return blurhash, nil
@@ -326,7 +329,8 @@ func generateWebpBlurhash(filepath string) (string, error) {
_ = file.Close()
if err != nil {
return "", gtserror.Newf("error decoding file %s: %w", filepath, err)
err := gtserror.Newf("error decoding file %s: %w", filepath, err)
return "", withDetails(err, codecDetails)
}
// for generating blurhashes, it's more
@@ -342,7 +346,8 @@ func generateWebpBlurhash(filepath string) (string, error) {
// Generate blurhash for the tiny thumbnail.
blurhash, err := blurhash.Encode(4, 3, tiny)
if err != nil {
return "", gtserror.Newf("error generating blurhash: %w", err)
err := gtserror.Newf("error generating blurhash: %w", err)
return "", withDetails(err, codecDetails)
}
return blurhash, nil
+8 -10
View File
@@ -20,6 +20,8 @@ package media
import (
"context"
"io"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
)
type Size string
@@ -79,11 +81,9 @@ type AdditionalMediaInfo struct {
// this media; defaults to 0.
FocusY *float32
// Set this to "true" if media originates
// from a domain that has a domain limit
// in place with a media policy of "reject".
// In this case, the media will not downloaded.
RejectMedia *bool
// Set this if media should be rejected due to
// some predetermined reason, e.g. domain policy.
RejectReason *gtsmodel.MediaErrorDetails
}
// AdditionalEmojiInfo represents additional information
@@ -118,11 +118,9 @@ type AdditionalEmojiInfo struct {
// should be placed in; defaults to "".
CategoryID *string
// Set this to "true" if emoji originates
// from a domain that has a domain limit
// in place with a media policy of "reject".
// In this case, the emoji will not downloaded.
RejectMedia *bool
// Set this if media should be rejected due to
// some predetermined reason, e.g. domain policy.
RejectReason *gtsmodel.MediaErrorDetails
}
// DataFunc represents a function used to retrieve the raw bytes of a piece of media.
+6 -6
View File
@@ -27,8 +27,7 @@ import (
"runtime"
"syscall"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"codeberg.org/gruf/go-bytesize"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"codeberg.org/gruf/go-iotools"
"codeberg.org/gruf/go-mmap"
)
@@ -159,7 +158,6 @@ func drainToTmp(rc io.ReadCloser) (string, error) {
// Limited reader (if any).
var lr *io.LimitedReader
var limit int64
// Reader type to use
// for draining to tmp.
@@ -174,7 +172,7 @@ func drainToTmp(rc io.ReadCloser) (string, error) {
rd = rct.Reader
// Extract limited reader if wrapped.
lr, limit = iotools.GetReaderLimit(rd)
lr, _ = iotools.GetReaderLimit(rd)
}
// Drain reader into tmp.
@@ -186,8 +184,10 @@ func drainToTmp(rc io.ReadCloser) (string, error) {
// Check to see if limit was reached,
// (produces more useful error messages).
if lr != nil && lr.N <= 0 {
err := fmt.Errorf("reached read limit %s", bytesize.Size(limit)) // #nosec G115 -- Just logging
return path, gtserror.SetLimitReached(err)
return path, withDetails(nil, gtsmodel.NewMediaErrorDetails(
gtsmodel.MediaErrorTypePolicy,
gtsmodel.MediaErrorTypePolicy_Size,
))
}
return path, nil
+44 -50
View File
@@ -35,7 +35,6 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/regexes"
"code.superseriousbusiness.org/gotosocial/internal/storage"
"code.superseriousbusiness.org/gotosocial/internal/uris"
"code.superseriousbusiness.org/gotosocial/internal/util"
)
// GetFile retrieves a file from storage and streams it back
@@ -95,8 +94,7 @@ func (p *Processor) GetFile(
}
}
// Check if there's a limit on
// the account's (sub)domain.
// Check if there's a limit on the account's (sub)domain.
limit, err := p.state.DB.MatchDomainLimit(ctx, acct.Domain)
if err != nil {
err := gtserror.Newf("error matching domain limit: %w", err)
@@ -184,20 +182,24 @@ func (p *Processor) getAttachmentContent(
// Start preparing API content model and other
// values depending on requested media size.
var content apimodel.Content
var mediaPath string
var mediaPath func(*gtsmodel.MediaAttachment) string
switch sizeStr {
// Original media size.
case media.SizeOriginal:
content.ContentType = attach.File.ContentType
content.ContentLength = int64(attach.File.FileSize)
mediaPath = attach.File.Path
mediaPath = func(a *gtsmodel.MediaAttachment) string {
return a.File.Path
}
// Thumbnail media size.
case media.SizeSmall:
content.ContentType = attach.Thumbnail.ContentType
content.ContentLength = int64(attach.Thumbnail.FileSize)
mediaPath = attach.Thumbnail.Path
mediaPath = func(a *gtsmodel.MediaAttachment) string {
return a.Thumbnail.Path
}
default:
const text = "invalid media size"
@@ -213,12 +215,12 @@ func (p *Processor) getAttachmentContent(
// Check media is meant
// to be cached locally.
if *attach.Cached {
if attach.Cached() {
// Check storage for media at determined path.
rc, err = p.state.Storage.GetStream(ctx, mediaPath)
// Check storage for media at determined fileserver path.
rc, err = p.state.Storage.GetStream(ctx, mediaPath(attach))
if err != nil && !storage.IsNotFound(err) {
err := gtserror.Newf("storage error getting media %s: %w", attach.URL, err)
err := gtserror.Newf("storage error getting cached media %s: %w", attach.URL, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
@@ -227,14 +229,10 @@ func (p *Processor) getAttachmentContent(
// This is local media without
// a cached attachment, unfulfillable!
if attach.IsLocal() {
return nil, gtserror.NewfWithCode(http.StatusNotFound,
"local media file not found: %s", attach.URL)
return nil, gtserror.NewWithCode(http.StatusNotFound,
"local media file not found")
}
// Whether the cached flag was set or
// not, we know it isn't in storage.
attach.Cached = util.Ptr(false)
// Attempt to recache this remote media.
attach, err = p.federator.RefreshMedia(ctx,
requestUser,
@@ -244,24 +242,22 @@ func (p *Processor) getAttachmentContent(
false, // async
)
if err != nil {
err := gtserror.Newf("error recaching media %s: %w", attach.URL, err)
return nil, gtserror.NewErrorNotFound(err)
err := gtserror.Newf("error recaching media %s: %w", attach.RemoteURL, err)
return nil, gtserror.WrapWithCode(http.StatusNotFound, err)
}
// Check storage for media at determined path.
rc, err = p.state.Storage.GetStream(ctx, mediaPath)
if err != nil && !storage.IsNotFound(err) {
err := gtserror.Newf("storage error getting media %s: %w", attach.URL, err)
return nil, gtserror.NewErrorInternalError(err)
} else if rc == nil {
return nil, gtserror.NewfWithCode(http.StatusNotFound,
"remote media file not found: %s", attach.URL)
// Check storage for media at determined fileserver path.
rc, err = p.state.Storage.GetStream(ctx, mediaPath(attach))
if err != nil {
err := gtserror.Newf("storage error getting recached media %s: %w", attach.RemoteURL, err)
return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err)
}
}
// If running on S3 storage with proxying disabled,
// just fetch a pre-signed URL instead of the content.
if url := p.state.Storage.URL(ctx, mediaPath); url != nil {
url := p.state.Storage.URL(ctx, mediaPath(attach))
if url != nil {
_ = rc.Close() // close storage stream
content.URL = url
return &content, nil
@@ -312,20 +308,24 @@ func (p *Processor) getEmojiContent(
// Start preparing API content model and other
// values depending on requested media size.
var content apimodel.Content
var emojiPath string
var emojiPath func(*gtsmodel.Emoji) string
switch sizeStr {
// Original emoji image.
case media.SizeOriginal:
content.ContentType = emoji.ImageContentType
content.ContentLength = int64(emoji.ImageFileSize)
emojiPath = emoji.ImagePath
emojiPath = func(e *gtsmodel.Emoji) string {
return e.ImagePath
}
// Static emoji image.
case media.SizeStatic:
content.ContentType = emoji.ImageStaticContentType
content.ContentLength = int64(emoji.ImageStaticFileSize)
emojiPath = emoji.ImageStaticPath
emojiPath = func(e *gtsmodel.Emoji) string {
return e.ImageStaticPath
}
default:
const text = "invalid emoji size"
@@ -341,12 +341,12 @@ func (p *Processor) getEmojiContent(
// Check emoji is meant
// to be cached locally.
if *emoji.Cached {
if emoji.Cached() {
// Check storage for emoji at determined image path.
rc, err = p.state.Storage.GetStream(ctx, emojiPath)
// Check storage for emoji at determined fileserver path.
rc, err = p.state.Storage.GetStream(ctx, emojiPath(emoji))
if err != nil && !storage.IsNotFound(err) {
err := gtserror.Newf("storage error getting emoji %s: %w", emoji.URI, err)
err := gtserror.Newf("storage error getting cached emoji %s: %w", emoji.URI, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
@@ -355,14 +355,10 @@ func (p *Processor) getEmojiContent(
// This is a local emoji without
// a cached image, unfulfillable!
if emoji.IsLocal() {
return nil, gtserror.NewfWithCode(http.StatusNotFound,
"local emoji image not found: %s", emoji.URI)
return nil, gtserror.NewWithCode(http.StatusNotFound,
"local emoji file not found")
}
// Whether the cached flag was set or
// not, we know it isn't in storage.
emoji.Cached = util.Ptr(false)
// Attempt to recache this remote emoji.
emoji, err = p.federator.RecacheEmoji(ctx,
emoji,
@@ -371,23 +367,21 @@ func (p *Processor) getEmojiContent(
)
if err != nil {
err := gtserror.Newf("error recaching emoji %s: %w", emoji.URI, err)
return nil, gtserror.NewErrorNotFound(err)
return nil, gtserror.WrapWithCode(http.StatusNotFound, err)
}
// Check storage for emoji at determined image path.
rc, err = p.state.Storage.GetStream(ctx, emojiPath)
if err != nil && !storage.IsNotFound(err) {
err := gtserror.Newf("storage error getting emoji %s after recache: %w", emoji.URI, err)
return nil, gtserror.NewErrorInternalError(err)
} else if rc == nil {
return nil, gtserror.NewfWithCode(http.StatusNotFound,
"remote emoji image not found: %s", emoji.URI)
// Check storage for emoji at determined fileserver path.
rc, err = p.state.Storage.GetStream(ctx, emojiPath(emoji))
if err != nil {
err := gtserror.Newf("storage error getting recached emoji %s: %w", emoji.URI, err)
return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err)
}
}
// If running on S3 storage with proxying disabled,
// just fetch a pre-signed URL instead of the content.
if url := p.state.Storage.URL(ctx, emojiPath); url != nil {
url := p.state.Storage.URL(ctx, emojiPath(emoji))
if url != nil {
_ = rc.Close() // close storage stream
content.URL = url
return &content, nil
+27 -20
View File
@@ -26,7 +26,6 @@ import (
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/media"
"code.superseriousbusiness.org/gotosocial/internal/util"
"code.superseriousbusiness.org/gotosocial/testrig"
"github.com/stretchr/testify/suite"
)
@@ -68,18 +67,21 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncached() {
// uncache the file from local
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
testAttachment.Cached = util.Ptr(false)
err := suite.db.UpdateByID(ctx, testAttachment, testAttachment.ID, "cached")
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.File.Path)
err := suite.storage.Delete(ctx, testAttachment.File.Path)
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.Thumbnail.Path)
suite.NoError(err)
// now fetch it
fileName := path.Base(testAttachment.File.Path)
requestingAccount := suite.testAccounts["local_account_1"]
testAttachment.File.Path = ""
testAttachment.Thumbnail.Path = ""
err = suite.db.UpdateAttachment(ctx, testAttachment, "thumbnail_path", "file_path")
suite.NoError(err)
// now fetch it
requestingAccount := suite.testAccounts["local_account_1"]
content, errWithCode := suite.mediaProcessor.GetFile(ctx, requestingAccount, &apimodel.GetContentRequestForm{
AccountID: testAttachment.AccountID,
MediaType: string(media.TypeAttachment),
@@ -107,7 +109,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncached() {
}
suite.NoError(err)
suite.True(*dbAttachment.Cached)
suite.True(dbAttachment.Cached())
// the file should be back in storage at the same path as before
refreshedBytes, err := suite.storage.Get(ctx, dbAttachment.File.Path)
@@ -120,18 +122,21 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncachedInterrupted() {
// uncache the file from local
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
testAttachment.Cached = util.Ptr(false)
err := suite.db.UpdateByID(ctx, testAttachment, testAttachment.ID, "cached")
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.File.Path)
err := suite.storage.Delete(ctx, testAttachment.File.Path)
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.Thumbnail.Path)
suite.NoError(err)
// now fetch it
fileName := path.Base(testAttachment.File.Path)
requestingAccount := suite.testAccounts["local_account_1"]
testAttachment.File.Path = ""
testAttachment.Thumbnail.Path = ""
err = suite.db.UpdateAttachment(ctx, testAttachment, "thumbnail_path", "file_path")
suite.NoError(err)
// now fetch it
requestingAccount := suite.testAccounts["local_account_1"]
content, errWithCode := suite.mediaProcessor.GetFile(ctx, requestingAccount, &apimodel.GetContentRequestForm{
AccountID: testAttachment.AccountID,
MediaType: string(media.TypeAttachment),
@@ -151,7 +156,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncachedInterrupted() {
var dbAttachment *gtsmodel.MediaAttachment
if !testrig.WaitFor(func() bool {
dbAttachment, _ = suite.db.GetAttachmentByID(ctx, testAttachment.ID)
return *dbAttachment.Cached
return dbAttachment.Cached()
}) {
suite.FailNow("timed out waiting for attachment to be updated")
}
@@ -171,18 +176,20 @@ func (suite *GetFileTestSuite) TestGetRemoteFileThumbnailUncached() {
suite.NoError(err)
// uncache the file from local
testAttachment.Cached = util.Ptr(false)
err = suite.db.UpdateByID(ctx, testAttachment, testAttachment.ID, "cached")
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.File.Path)
suite.NoError(err)
err = suite.storage.Delete(ctx, testAttachment.Thumbnail.Path)
suite.NoError(err)
// now fetch the thumbnail
fileName := path.Base(testAttachment.File.Path)
requestingAccount := suite.testAccounts["local_account_1"]
testAttachment.File.Path = ""
testAttachment.Thumbnail.Path = ""
err = suite.db.UpdateAttachment(ctx, testAttachment, "thumbnail_path", "file_path")
suite.NoError(err)
// now fetch the thumbnail
requestingAccount := suite.testAccounts["local_account_1"]
content, errWithCode := suite.mediaProcessor.GetFile(ctx, requestingAccount, &apimodel.GetContentRequestForm{
AccountID: testAttachment.AccountID,
MediaType: string(media.TypeAttachment),
+9 -11
View File
@@ -59,17 +59,6 @@ type MediaStandardTestSuite struct {
mediaProcessor mediaprocessing.Processor
}
func (suite *MediaStandardTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testAttachments = testrig.NewTestAttachments()
suite.testStatuses = testrig.NewTestStatuses()
suite.testRemoteAttachments = testrig.NewTestFediAttachments("../../../testrig/media")
suite.testDomainLimits = testrig.NewTestDomainLimits()
}
func (suite *MediaStandardTestSuite) SetupTest() {
suite.state.Caches.Init()
@@ -94,6 +83,15 @@ func (suite *MediaStandardTestSuite) SetupTest() {
suite.mediaProcessor = mediaprocessing.New(&common, &suite.state, suite.tc, federator, suite.mediaManager, suite.transportController)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
suite.testTokens = testrig.NewTestTokens()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testAttachments = testrig.NewTestAttachments()
suite.testStatuses = testrig.NewTestStatuses()
suite.testRemoteAttachments = testrig.NewTestFediAttachments("../../../testrig/media")
suite.testDomainLimits = testrig.NewTestDomainLimits()
}
func (suite *MediaStandardTestSuite) TearDownTest() {
+174 -175
View File
@@ -684,7 +684,20 @@ func AttachmentToAPIAttachment(media *gtsmodel.MediaAttachment) apimodel.Attachm
api.Type = media.Type.String()
api.ID = media.ID
if media.File.Path != "" {
// Set initial API model attachment fields.
api.Blurhash = util.PtrIf(media.Blurhash)
api.RemoteURL = util.PtrIf(media.RemoteURL)
api.PreviewRemoteURL = util.PtrIf(media.Thumbnail.RemoteURL)
api.Description = util.PtrIf(media.Description)
if media.Error != 0 {
// Set media error string.
api.Error = new(string)
*api.Error = media.Error.String()
return api
}
if media.URL != "" {
// Allocate media metadata object.
api.Meta = new(apimodel.MediaMeta)
@@ -699,7 +712,7 @@ func AttachmentToAPIAttachment(media *gtsmodel.MediaAttachment) apimodel.Attachm
// If the URL is set, either the file is currently
// processing, or is successfully stored locally.
api.TextURL = util.Ptr(media.URL)
api.URL = util.Ptr(media.URL)
api.URL = api.TextURL
// Only add file details if we have any stored.
if media.FileMeta.Original != zeroOriginal {
@@ -715,7 +728,7 @@ func AttachmentToAPIAttachment(media *gtsmodel.MediaAttachment) apimodel.Attachm
}
}
if media.Thumbnail.Path != "" {
if media.Thumbnail.URL != "" {
if api.Meta == nil {
// Allocate media metadata object.
api.Meta = new(apimodel.MediaMeta)
@@ -744,12 +757,6 @@ func AttachmentToAPIAttachment(media *gtsmodel.MediaAttachment) apimodel.Attachm
}
}
// Set remaining API attachment fields.
api.Blurhash = util.PtrIf(media.Blurhash)
api.RemoteURL = util.PtrIf(media.RemoteURL)
api.PreviewRemoteURL = util.PtrIf(media.Thumbnail.RemoteURL)
api.Description = util.PtrIf(media.Description)
return api
}
@@ -905,28 +912,28 @@ func (c *Converter) statusToAPIStatus(
return nil, err
}
// Convert author to API model.
acct, err := c.AccountToAPIAccountPublic(ctx, status.Account)
// Convert status author account to frontend API model.
apiStatus.Account, err = c.AccountToAPIAccountPublic(ctx,
status.Account)
if err != nil {
return nil, gtserror.Newf("error converting status acct: %w", err)
return nil, gtserror.Newf("error converting status author: %w", err)
}
apiStatus.Account = acct
// Convert author of boosted
// status (if set) to API model.
if apiStatus.Reblog != nil {
boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount)
apiStatus.Reblog.Account, err = c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount)
if err != nil {
return nil, gtserror.Newf("error converting boost acct: %w", err)
return nil, gtserror.Newf("error converting boost author: %w", err)
}
apiStatus.Reblog.Account = boostAcct
}
if placeholdAttachments {
var attachNote string
// Normalize status for API by pruning attachments
// that were not able to be locally stored, and replacing
// them with a helpful message + links to remote.
var attachNote string
attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
apiStatus.Content += attachNote
@@ -949,7 +956,6 @@ func (c *Converter) statusToAPIStatus(
if err != nil {
return nil, gtserror.Newf("error deriving 'pending reply' note: %w", err)
}
apiStatus.Content += pendingNote
}
}
@@ -1331,7 +1337,7 @@ func (c *Converter) baseStatusToFrontend(
apiStatus.Poll, err = c.PollToAPIPoll(ctx, requester, poll)
if err != nil {
return nil, fmt.Errorf("error converting poll: %w", err)
return nil, gtserror.Newf("error converting poll: %w", err)
}
}
@@ -1347,9 +1353,6 @@ func (c *Converter) baseStatusToFrontend(
"error getting interactions for status %s for account %s: %v",
status.URI, requester.URI, err,
)
// Ensure non-nil object.
interacts = new(statusInteractions)
}
apiStatus.Favourited = interacts.Favourited
apiStatus.Bookmarked = interacts.Bookmarked
@@ -2942,159 +2945,6 @@ func (c *Converter) ScheduledStatusToAPIScheduledStatus(ctx context.Context, sta
return apiScheduledStatus, nil
}
// attachmentsToAPI converts database model media attachments (fetching
// using IDs if necessary) to frontend API attachment models. all errors
// are caught and logged, with the calling function name as a prefix.
func (c *Converter) attachmentsToAPI(
ctx context.Context,
attachments []*gtsmodel.MediaAttachment,
attachmentIDs []string,
) []*apimodel.Attachment {
caller := log.Caller(3)
// Check if media attachments are populated.
if len(attachments) != len(attachmentIDs) {
var err error
// Media attachments are not populated, fetch from the database.
attachments, err = c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs)
if err != nil {
log.Errorf(ctx, "%s: error getting media: %v", caller, err)
return []*apimodel.Attachment{}
}
}
// Convert all db media attachments to slice of API models.
apiModels := make([]*apimodel.Attachment, len(attachments))
if len(apiModels) != len(attachments) {
panic(gtserror.New("bound check elimination"))
}
for i, media := range attachments {
apiModel := AttachmentToAPIAttachment(media)
apiModels[i] = &apiModel
}
return apiModels
}
// emojisToAPI converts database model emojis (fetching using IDs if
// necessary) to frontend API emoji models. all errors are caught and
// logged, with the calling function name as a prefix.
func (c *Converter) emojisToAPI(
ctx context.Context,
emojis []*gtsmodel.Emoji,
emojiIDs []string,
) []apimodel.Emoji {
caller := log.Caller(3)
// Check if emojis are populated.
if len(emojis) != len(emojiIDs) {
var err error
// Emojis are not populated, fetch from the database.
emojis, err = c.state.DB.GetEmojisByIDs(ctx, emojiIDs)
if err != nil {
log.Errorf(ctx, "%s: error getting emojis: %v", caller, err)
return []apimodel.Emoji{}
}
}
// Preallocate a biggest-case slice of frontend emojis.
apiModels := make([]apimodel.Emoji, 0, len(emojis))
for _, emoji := range emojis {
// Convert each database emoji to API model.
apiModel, err := c.EmojiToAPIEmoji(ctx, emoji)
if err != nil {
log.Errorf(ctx, "%s: error converting emoji %s: %v", caller, emoji.ShortcodeDomain(), err)
continue
}
// Append API model to the return slice.
apiModels = append(apiModels, apiModel)
}
return apiModels
}
// mentionsToAPI converts database model mentions (fetching using IDs if
// necessary) to frontend API mention models. all errors are caught and
// logged, with the calling function name as a prefix.
func (c *Converter) mentionsToAPI(
ctx context.Context,
mentions []*gtsmodel.Mention,
mentionIDs []string,
) []apimodel.Mention {
caller := log.Caller(3)
// Check if mentions are populated.
if len(mentions) != len(mentionIDs) {
var err error
// Mentions are not populated, fetch from the database.
mentions, err = c.state.DB.GetMentions(ctx, mentionIDs)
if err != nil {
log.Errorf(ctx, "%s: error getting mentions: %v", caller, err)
return []apimodel.Mention{}
}
}
// Preallocate a biggest-case slice of frontend mentions.
apiModels := make([]apimodel.Mention, 0, len(mentions))
for _, mention := range mentions {
// Convert each database mention to frontend API model.
apiModel, err := c.MentionToAPIMention(ctx, mention)
if err != nil {
log.Errorf(ctx, "%s: error converting mention %s: %v", caller, mention.ID, err)
continue
}
// Append API model to the return slice.
apiModels = append(apiModels, apiModel)
}
return apiModels
}
// tagsToAPI converts database model tags (fetching using IDs if
// necessary) to frontend API tag models. all errors are caught
// and logged, with the calling function name as a prefix.
func (c *Converter) tagsToAPI(
ctx context.Context,
tags []*gtsmodel.Tag,
tagIDs []string,
) []apimodel.Tag {
caller := log.Caller(3)
// Check if mentions are populated.
if len(tags) != len(tagIDs) {
var err error
// Tags not populated, fetch from database.
tags, err = c.state.DB.GetTags(ctx, tagIDs)
if err != nil {
log.Errorf(ctx, "%s: error getting tags: %v", caller, err)
return []apimodel.Tag{}
}
}
// Convert all db tags to slice of API models.
apiModels := make([]apimodel.Tag, len(tags))
if len(apiModels) != len(tags) {
panic(gtserror.New("bound check elimination"))
}
for i, tag := range tags {
apiModels[i] = TagToAPITag(tag, false, nil)
}
return apiModels
}
func (c *Converter) DomainLimitToAPIDomainLimit(
ctx context.Context,
domainLimit *gtsmodel.DomainLimit,
@@ -3221,3 +3071,152 @@ func domainLimitStatusesPolicyToFilterAction(p gtsmodel.StatusesPolicy) apimodel
return apimodel.FilterActionNone
}
}
// attachmentsToAPI converts database model media attachments (fetching
// using IDs if necessary) to frontend API attachment models. all errors
// are caught and logged, with the calling function name as a prefix.
func (c *Converter) attachmentsToAPI(
ctx context.Context,
attachments []*gtsmodel.MediaAttachment,
attachmentIDs []string,
) []*apimodel.Attachment {
// Check if media attachments are populated.
if len(attachments) != len(attachmentIDs) {
var err error
// Media attachments are not populated, fetch from the database.
attachments, err = c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error getting media: %w", err))
return []*apimodel.Attachment{}
}
}
// Convert all db media attachments to slice of API models.
apiModels := make([]*apimodel.Attachment, len(attachments))
if len(apiModels) != len(attachments) {
panic(gtserror.New("bound check elimination"))
}
for i, media := range attachments {
apiModel := AttachmentToAPIAttachment(media)
apiModels[i] = &apiModel
}
return apiModels
}
// emojisToAPI converts database model emojis (fetching using IDs if
// necessary) to frontend API emoji models. all errors are caught and
// logged, with the calling function name as a prefix.
func (c *Converter) emojisToAPI(
ctx context.Context,
emojis []*gtsmodel.Emoji,
emojiIDs []string,
) []apimodel.Emoji {
// Check if emojis are populated.
if len(emojis) != len(emojiIDs) {
var err error
// Emojis are not populated, fetch from the database.
emojis, err = c.state.DB.GetEmojisByIDs(ctx, emojiIDs)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error getting emojis: %w", err))
return []apimodel.Emoji{}
}
}
// Preallocate a biggest-case slice of frontend emojis.
apiModels := make([]apimodel.Emoji, 0, len(emojis))
for _, emoji := range emojis {
// Convert each database emoji to API model.
apiModel, err := c.EmojiToAPIEmoji(ctx, emoji)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error converting emoji %s: %w", emoji.ShortcodeDomain(), err))
continue
}
// Append API model to the return slice.
apiModels = append(apiModels, apiModel)
}
return apiModels
}
// mentionsToAPI converts database model mentions (fetching using IDs if
// necessary) to frontend API mention models. all errors are caught and
// logged, with the calling function name as a prefix.
func (c *Converter) mentionsToAPI(
ctx context.Context,
mentions []*gtsmodel.Mention,
mentionIDs []string,
) []apimodel.Mention {
// Check if mentions are populated.
if len(mentions) != len(mentionIDs) {
var err error
// Mentions are not populated, fetch from the database.
mentions, err = c.state.DB.GetMentions(ctx, mentionIDs)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error getting mentions: %w", err))
return []apimodel.Mention{}
}
}
// Preallocate a biggest-case slice of frontend mentions.
apiModels := make([]apimodel.Mention, 0, len(mentions))
for _, mention := range mentions {
// Convert each database mention to frontend API model.
apiModel, err := c.MentionToAPIMention(ctx, mention)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error converting mention %s: %w", mention.ID, err))
continue
}
// Append API model to the return slice.
apiModels = append(apiModels, apiModel)
}
return apiModels
}
// tagsToAPI converts database model tags (fetching using IDs if
// necessary) to frontend API tag models. all errors are caught
// and logged, with the calling function name as a prefix.
func (c *Converter) tagsToAPI(
ctx context.Context,
tags []*gtsmodel.Tag,
tagIDs []string,
) []apimodel.Tag {
// Check if mentions are populated.
if len(tags) != len(tagIDs) {
var err error
// Tags not populated, fetch from database.
tags, err = c.state.DB.GetTags(ctx, tagIDs)
if err != nil {
log.Error(ctx, gtserror.NewfAt(3, "error getting tags: %w", err))
return []apimodel.Tag{}
}
}
// Convert all db tags to slice of API models.
apiModels := make([]apimodel.Tag, len(tags))
if len(apiModels) != len(tags) {
panic(gtserror.New("bound check elimination"))
}
for i, tag := range tags {
apiModels[i] = TagToAPITag(tag, false, nil)
}
return apiModels
}
@@ -1002,7 +1002,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
"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\u003cdiv class=\"gts-system-message gts-placeholder-attachments\"\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\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/div\u003e",
"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\u003cdiv class=\"gts-system-message gts-placeholder-attachments\"\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\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] (error: unsupported media type)\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.] (error: unsupported media type)\u003c/li\u003e\u003c/ul\u003e\u003c/div\u003e",
"reblog": null,
"account": {
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
@@ -1270,6 +1270,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
"meta": null,
"description": "SVG line art of a sloth, public domain",
"blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of",
"error": "unsupported media type",
"Sensitive": true,
"MIMEType": "",
"PreviewMIMEType": "",
@@ -1286,6 +1287,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
"meta": null,
"description": "Jolly salsa song, public domain.",
"blurhash": null,
"error": "unsupported media type",
"Sensitive": true,
"MIMEType": "",
"PreviewMIMEType": "",
@@ -3880,7 +3882,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIEdits() {
"preview_remote_url": null,
"meta": null,
"description": "Jolly salsa song, public domain.",
"blurhash": null
"blurhash": null,
"error": "unsupported media type"
}
],
"emojis": []
@@ -3987,7 +3990,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIEdits() {
"preview_remote_url": null,
"meta": null,
"description": "Jolly salsa song, public domain.",
"blurhash": null
"blurhash": null,
"error": "unsupported media type"
}
],
"emojis": []
+14 -18
View File
@@ -20,7 +20,6 @@ package typeutils
import (
"context"
"errors"
"fmt"
"math"
"net/url"
"path"
@@ -67,33 +66,27 @@ type statusInteractions struct {
Pinned bool
}
func (c *Converter) interactionsWithStatusForAccount(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*statusInteractions, error) {
si := &statusInteractions{}
func (c *Converter) interactionsWithStatusForAccount(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (si statusInteractions, err error) {
if requestingAccount != nil {
faved, err := c.state.DB.IsStatusFavedBy(ctx, s.ID, requestingAccount.ID)
si.Favourited, err = c.state.DB.IsStatusFavedBy(ctx, s.ID, requestingAccount.ID)
if err != nil {
return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err)
return si, gtserror.Newf("error checking if requesting account has faved status: %s", err)
}
si.Favourited = faved
reblogged, err := c.state.DB.IsStatusBoostedBy(ctx, s.ID, requestingAccount.ID)
si.Reblogged, err = c.state.DB.IsStatusBoostedBy(ctx, s.ID, requestingAccount.ID)
if err != nil {
return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err)
return si, gtserror.Newf("error checking if requesting account has reblogged status: %s", err)
}
si.Reblogged = reblogged
muted, err := c.state.DB.IsThreadMutedByAccount(ctx, s.ThreadID, requestingAccount.ID)
si.Muted, err = c.state.DB.IsThreadMutedByAccount(ctx, s.ThreadID, requestingAccount.ID)
if err != nil {
return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err)
return si, gtserror.Newf("error checking if requesting account has muted status: %s", err)
}
si.Muted = muted
bookmarked, err := c.state.DB.IsStatusBookmarkedBy(ctx, requestingAccount.ID, s.ID)
si.Bookmarked, err = c.state.DB.IsStatusBookmarkedBy(ctx, requestingAccount.ID, s.ID)
if err != nil {
return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err)
return si, gtserror.Newf("error checking if requesting account has bookmarked status: %s", err)
}
si.Bookmarked = bookmarked
// The only time 'pinned' should be true is if the
// requesting account is looking at its OWN status.
@@ -140,10 +133,10 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
// Extract non-locally stored attachments into a new slice,
// deleting them from input slice. This checks whether any
// file metadata has been set, which will only happen in the
// download error has been set, which will only happen in the
// case that we successfully finish processing an attachment.
arr = slices.DeleteFunc(arr, func(elem *apimodel.Attachment) bool {
if elem.Meta == nil {
if elem.Error != nil {
nonLocal = append(nonLocal, elem)
return true
}
@@ -186,6 +179,9 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
note.WriteString(*d)
note.WriteString(`]`)
}
note.WriteString(` (error: `)
note.WriteString(*a.Error)
note.WriteString(`)`)
note.WriteString(`</li>`)
}
note.WriteString(`</ul>`)
+9 -60
View File
@@ -555,7 +555,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj#main-key",
HeaderMediaAttachmentID: "01PFPMWK2FF0D9WMHEJHR07C3R",
HeaderMediaAttachmentID: "01G549FP8065NKWBPTWHP6Y3PD",
HidesToPublicFromUnauthedWeb: util.Ptr(false),
HidesCcPublicFromUnauthedWeb: util.Ptr(true),
},
@@ -745,7 +745,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "Black and white image of some 50's style text saying: Welcome On Board",
ScheduledStatusID: "",
Blurhash: "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg",
ContentType: "image/jpeg",
@@ -760,7 +759,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_1_status_4_attachment_1": {
ID: "01F8MH7TDVANYKWVE8VVKFPJTJ",
@@ -791,7 +789,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "90's Trent Reznor turning to the camera",
ScheduledStatusID: "",
Blurhash: "LCDRH758KOxsEMNxENEM9]}?aKxZ",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH7TDVANYKWVE8VVKFPJTJ.gif",
ContentType: "image/gif",
@@ -806,7 +803,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_1_status_4_attachment_2": {
ID: "01CDR64G398ADCHXK08WWTHEZ5",
@@ -840,7 +836,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "A cow adorably licking another cow!",
ScheduledStatusID: "",
Blurhash: "L9B|BBY8yZtS~AxZV@t6,njEjZV@",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4",
ContentType: "video/mp4",
@@ -855,7 +850,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_1_unattached_1": {
ID: "01F8MH8RMYQ6MSNY3JM2XT1CQ5",
@@ -886,7 +880,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "the oh you meme",
ScheduledStatusID: "",
Blurhash: "LNABP8o#Dge,S6M}axxVEQjYxWbH",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg",
ContentType: "image/jpeg",
@@ -901,7 +894,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_1_avatar": {
ID: "01F8MH58A357CV5K7R7TJMSH6S",
@@ -932,7 +924,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "a green goblin looking nasty",
ScheduledStatusID: "",
Blurhash: "LHI:dk=G|rj]H[J-5roJvnr@Opag",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg",
ContentType: "image/jpeg",
@@ -947,7 +938,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(true),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_1_header": {
ID: "01PFPMWK2FF0D9WMHEJHR07C3Q",
@@ -978,7 +968,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "A very old-school screenshot of the original team fortress mod for quake",
ScheduledStatusID: "",
Blurhash: "L17KPDs:$ykDJroJ-RoJ0fR+xVjY",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg",
ContentType: "image/jpeg",
@@ -993,7 +982,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(true),
Cached: util.Ptr(true),
},
"local_account_1_status_8_attachment_1": {
ID: "01J2M20K6K9XQC4WSB961YJHV6",
@@ -1027,7 +1015,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "This is a track from Nine Inch Nails' \"Ghosts I-V\" album.\n\nThis is the third track from \"Ghosts II\".",
ScheduledStatusID: "",
Blurhash: "LZDJO?ayIUof01j[xuayxuayayj[",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01J2M20K6K9XQC4WSB961YJHV6.mp3",
ContentType: "audio/mpeg",
@@ -1042,7 +1029,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_2_status_9_attachment_1": {
ID: "01JDQ164HM08SGJ7ZEK9003Z4B",
@@ -1051,16 +1037,15 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
RemoteURL: "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",
CreatedAt: TimeMustParse("2024-11-01T10:01:00+02:00"),
Type: gtsmodel.FileTypeUnknown,
Error: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeCodec, gtsmodel.MediaErrorTypeCodec_Unsupported),
FileMeta: gtsmodel.FileMeta{},
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
Description: "Jolly salsa song, public domain.",
Blurhash: "",
Processing: gtsmodel.ProcessingStatusProcessed,
File: gtsmodel.File{},
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
"local_account_3_avatar": {
ID: "01JPHQZ0ZHC2AXJK1JQNXRXQZN",
@@ -1091,7 +1076,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LRF~2LIU0esp-qRjR*aeJ$s;iwW.",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg",
ContentType: "image/jpeg",
@@ -1106,7 +1090,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(true),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_3_header": {
ID: "01JPHRB7F2RXPTEQFRYC85EPD9",
@@ -1137,7 +1120,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L9I5h:%M%M?a~os:D*bFMybFM{jI",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png",
ContentType: "image/png",
@@ -1152,7 +1134,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(true),
Cached: util.Ptr(true),
},
// sickos
"local_account_3_status_1_attachment_1": {
@@ -1184,7 +1165,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L~EqXWX5t6og%jW=owa~N1WFjYWC",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg",
ContentType: "image/jpeg",
@@ -1199,7 +1179,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// marge
"local_account_3_status_1_attachment_2": {
@@ -1231,7 +1210,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LGH1i6RpD;-,0DoZaIogA2N3xZI]",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPTSFNQDAGTHP49DXSD0BM.png",
ContentType: "image/png",
@@ -1246,7 +1224,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// sloth-gear
"local_account_3_status_1_attachment_3": {
@@ -1278,7 +1255,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LOE.|bxZx]j[~pt7WWWW%Lj@%Mj[",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPYJ6N2E2R7GAJ1XECXNV5.webp",
ContentType: "image/webp",
@@ -1293,7 +1269,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// you-posted
"local_account_3_status_1_attachment_4": {
@@ -1325,7 +1300,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L00+zhoLNubHj[fQa|fQ9tWVw{jZ",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ4WXEA52VVR9V1HN7E0RS.png",
ContentType: "image/png",
@@ -1340,7 +1314,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// buscemi
"local_account_3_status_1_attachment_5": {
@@ -1372,7 +1345,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L5A9A=}?J*5m56Rk={$%O?Nb$M$i",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ9VBZBMSTVN56QN3R5188.jpeg",
ContentType: "image/jpeg",
@@ -1387,7 +1359,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// butt
"local_account_3_status_1_attachment_6": {
@@ -1419,7 +1390,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LWLN.4~q00ofxuxu-;%M9F-;-;xu",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPG1RZPRH3Y00VSA3RQ2SJWP.gif",
ContentType: "image/gif",
@@ -1434,7 +1404,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// bunny
"local_account_3_status_2_attachment_1": {
@@ -1468,7 +1437,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LEQcn{?bfQ?b~qoffQoffQfQfQfQ",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFKQ86GT9W76SWPHE9P8JB.webm",
ContentType: "video/webm",
@@ -1483,7 +1451,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// computerbye
"local_account_3_status_2_attachment_2": {
@@ -1518,7 +1485,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LLHUzr-;o#_2~q-:IV%Mxu%MM{M{",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFSCVGGH02FX9VJMXGXN45.gif",
ContentType: "image/gif",
@@ -1533,7 +1499,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// diarrhea
"local_account_3_status_2_attachment_3": {
@@ -1568,7 +1533,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L78qTmNG00xZkWxsIURQ01s;?aR*",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFW5HKFWQNQ954P5KNXWSR.gif",
ContentType: "image/gif",
@@ -1583,7 +1547,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// ffmpreg
"local_account_3_status_2_attachment_4": {
@@ -1615,7 +1578,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LOCX.y}rIpE3,?w{S4W;9vENX8t6",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg",
ContentType: "image/jpeg",
@@ -1630,7 +1592,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// notabug
"local_account_3_status_2_attachment_5": {
@@ -1662,7 +1623,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LTGbrRxAE1og0OR:xve-OFs6kCWY",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHG32F7M6F084WKEGAYJ40X.jpeg",
ContentType: "image/jpeg",
@@ -1677,7 +1637,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"remote_account_1_status_1_attachment_1": {
ID: "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
@@ -1708,7 +1667,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
ScheduledStatusID: "",
Blurhash: "L3Q9_@4n9E?axW4mD$Mx~q00Di%L",
Processing: 2,
File: gtsmodel.File{
Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg",
ContentType: "image/jpeg",
@@ -1722,12 +1680,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"remote_account_3_header": {
ID: "01PFPMWK2FF0D9WMHEJHR07C3R",
ID: "01G549FP8065NKWBPTWHP6Y3PD",
StatusID: "",
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg",
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01G549FP8065NKWBPTWHP6Y3PD.jpg",
RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg",
CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"),
Type: gtsmodel.FileTypeImage,
@@ -1753,21 +1710,19 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Description: "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
ScheduledStatusID: "",
Blurhash: "L3Q9_@4n9E?axW4mD$Mx~q00Di%L",
Processing: 2,
File: gtsmodel.File{
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01G549FP8065NKWBPTWHP6Y3PD.jpeg",
ContentType: "image/jpeg",
FileSize: 19310,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg",
Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01G549FP8065NKWBPTWHP6Y3PD.jpeg",
ContentType: "image/webp",
FileSize: 20395,
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.webp",
URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01G549FP8065NKWBPTWHP6Y3PD.webp",
},
Avatar: util.Ptr(false),
Header: util.Ptr(true),
Cached: util.Ptr(true),
},
"remote_account_2_status_1_attachment_1": {
ID: "01HE7Y3C432WRSNS10EZM86SA5",
@@ -1797,7 +1752,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
AccountID: "01FHMQX3GAABWSM0S2VZEC2SWC",
Description: "Photograph of a sloth, Public Domain.",
Blurhash: "LKE3VIw}0KD%a2o{M|t7NFWps:t7",
Processing: 2,
File: gtsmodel.File{
Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg",
ContentType: "image/jpg",
@@ -1811,7 +1765,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"remote_account_2_status_1_attachment_2": {
ID: "01HE7ZFX9GKA5ZZVD4FACABSS9",
@@ -1820,16 +1773,15 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
RemoteURL: "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg",
CreatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"),
Type: gtsmodel.FileTypeUnknown,
Error: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeCodec, gtsmodel.MediaErrorTypeCodec_Unsupported),
FileMeta: gtsmodel.FileMeta{},
AccountID: "01FHMQX3GAABWSM0S2VZEC2SWC",
Description: "SVG line art of a sloth, public domain",
Blurhash: "L26*j+~qE1RP?wxut7ofRlM{R*of",
Processing: 2,
File: gtsmodel.File{},
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
"remote_account_2_status_1_attachment_3": {
ID: "01HE88YG74PVAB81PX2XA9F3FG",
@@ -1838,16 +1790,15 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
RemoteURL: "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3",
CreatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"),
Type: gtsmodel.FileTypeUnknown,
Error: gtsmodel.NewMediaErrorDetails(gtsmodel.MediaErrorTypeCodec, gtsmodel.MediaErrorTypeCodec_Unsupported),
FileMeta: gtsmodel.FileMeta{},
AccountID: "01FHMQX3GAABWSM0S2VZEC2SWC",
Description: "Jolly salsa song, public domain.",
Blurhash: "",
Processing: 2,
File: gtsmodel.File{},
Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
}
}
@@ -1875,7 +1826,6 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji {
URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
VisibleInPicker: util.Ptr(true),
CategoryID: "01GGQ8V4993XK67B2JB396YFB7",
Cached: util.Ptr(true),
},
"yell": {
ID: "01GD5KP5CQEE1R3X43Y1EHS2CW",
@@ -1897,7 +1847,6 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji {
URI: "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW",
VisibleInPicker: util.Ptr(false),
CategoryID: "",
Cached: util.Ptr(false),
},
}
}