From 21bb324156f582e918a097ea744e52fc21b2ddf4 Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:01:16 +0000 Subject: [PATCH] [chore] media and emoji refactoring (#3000) * start updating media manager interface ready for storing attachments / emoji right away * store emoji and media as uncached immediately, then (re-)cache on Processing{}.Load() * remove now unused media workers * fix tests and issues * fix another test! * fix emoji activitypub uri setting behaviour, fix remainder of test compilation issues * fix more tests * fix (most of) remaining tests, add debouncing to repeatedly failing media / emojis * whoops, rebase issue * remove kim's whacky experiments * do some reshuffling, ensure emoji uri gets set * ensure marked as not cached on cleanup * tweaks to media / emoji processing to handle context canceled better * ensure newly fetched emojis actually get set in returned slice * use different varnames to be a bit more obvious * move emoji refresh rate limiting to dereferencer * add exported dereferencer functions for remote media, use these for recaching in processor * add check for nil attachment in updateAttachment() * remove unused emoji and media fields + columns * see previous commit * fix old migrations expecting image_updated_at to exists (from copies of old models) * remove freshness checking code (seems to be broken...) * fix error arg causing nil ptr exception * finish documentating functions with comments, slight tweaks to media / emoji deref error logic * remove some extra unneeded boolean checking * finish writing documentation (code comments) for exported media manager methods * undo changes to migration snapshot gtsmodels, updated failing migration to have its own snapshot * move doesColumnExist() to util.go in migrations package --- .../api/activitypub/users/inboxpost_test.go | 12 +- internal/api/client/admin/emojicreate_test.go | 2 +- internal/api/client/admin/emojiupdate_test.go | 9 +- internal/api/fileserver/servefile_test.go | 8 +- internal/cache/size.go | 3 - internal/cleaner/media_test.go | 5 +- .../20230521105850_emoji_empty_domain_fix.go | 2 +- .../emoji.go | 53 ++ ...0240613091853_drop_unused_media_columns.go | 68 ++ internal/db/bundb/migrations/util.go | 40 ++ internal/federation/dereferencing/account.go | 184 ++--- internal/federation/dereferencing/emoji.go | 389 ++++++---- .../federation/dereferencing/emoji_test.go | 45 +- internal/federation/dereferencing/media.go | 215 ++++++ internal/federation/dereferencing/status.go | 241 ++++--- internal/federation/dereferencing/util.go | 124 +--- internal/gtsmodel/emoji.go | 15 +- internal/gtsmodel/mediaattachment.go | 32 +- internal/media/image.go | 37 +- internal/media/manager.go | 670 +++++++++--------- internal/media/manager_test.go | 409 ++++++----- internal/media/processingemoji.go | 209 +++--- internal/media/processingmedia.go | 287 ++++---- internal/media/refetch.go | 6 +- internal/media/types.go | 76 +- internal/media/util.go | 1 - internal/processing/account/account_test.go | 2 +- internal/processing/account/update.go | 102 +-- internal/processing/admin/admin.go | 35 +- internal/processing/admin/debug_apurl.go | 2 +- internal/processing/admin/email.go | 2 +- internal/processing/admin/emoji.go | 533 ++++++-------- internal/processing/admin/media.go | 4 +- internal/processing/common/common.go | 4 + internal/processing/common/media.go | 98 +++ internal/processing/instance.go | 20 +- internal/processing/media/create.go | 27 +- internal/processing/media/getfile.go | 456 ++++++------ internal/processing/media/media.go | 17 +- internal/processing/media/media_test.go | 9 +- internal/processing/polls/poll_test.go | 2 +- internal/processing/processor.go | 6 +- internal/processing/status/status_test.go | 2 +- internal/storage/storage.go | 2 +- internal/typeutils/internaltoas.go | 2 +- internal/workers/workers.go | 11 - testrig/testmodels.go | 24 - testrig/util.go | 2 - 48 files changed, 2578 insertions(+), 1926 deletions(-) create mode 100644 internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix/emoji.go create mode 100644 internal/db/bundb/migrations/20240613091853_drop_unused_media_columns.go create mode 100644 internal/db/bundb/migrations/util.go create mode 100644 internal/federation/dereferencing/media.go create mode 100644 internal/processing/common/media.go diff --git a/internal/api/activitypub/users/inboxpost_test.go b/internal/api/activitypub/users/inboxpost_test.go index 9d863f234..64c9f7e6c 100644 --- a/internal/api/activitypub/users/inboxpost_test.go +++ b/internal/api/activitypub/users/inboxpost_test.go @@ -376,7 +376,17 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { } // emojis should be updated - suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) + var haveUpdatedEmoji bool + for _, emoji := range dbUpdatedAccount.Emojis { + if emoji.Shortcode == testEmoji.Shortcode && + emoji.Domain == testEmoji.Domain && + emoji.ImageRemoteURL == emoji.ImageRemoteURL && + emoji.ImageStaticRemoteURL == emoji.ImageStaticRemoteURL { + haveUpdatedEmoji = true + break + } + } + suite.True(haveUpdatedEmoji) // account should be freshly fetched suite.WithinDuration(time.Now(), dbUpdatedAccount.FetchedAt, 10*time.Second) diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go index 46139df47..be39ebdf5 100644 --- a/internal/api/client/admin/emojicreate_test.go +++ b/internal/api/client/admin/emojicreate_test.go @@ -281,7 +281,7 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateAlreadyExists() { suite.NoError(err) suite.NotEmpty(b) - suite.Equal(`{"error":"Conflict: emoji with shortcode rainbow already exists"}`, string(b)) + suite.Equal(`{"error":"Conflict: emoji with shortcode already exists"}`, string(b)) } func TestEmojiCreateTestSuite(t *testing.T) { diff --git a/internal/api/client/admin/emojiupdate_test.go b/internal/api/client/admin/emojiupdate_test.go index 676363e39..11beaeaa9 100644 --- a/internal/api/client/admin/emojiupdate_test.go +++ b/internal/api/client/admin/emojiupdate_test.go @@ -20,6 +20,7 @@ package admin_test import ( "context" "encoding/json" + "io" "io/ioutil" "net/http" "net/http/httptest" @@ -370,10 +371,10 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() { defer result.Body.Close() // check the response - b, err := ioutil.ReadAll(result.Body) + b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"Bad Request: emoji 01GD5KP5CQEE1R3X43Y1EHS2CW is not a local emoji, cannot update it via this endpoint"}`, string(b)) + suite.Equal(`{"error":"Bad Request: cannot modify remote emoji"}`, string(b)) } func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() { @@ -440,7 +441,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() { b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"Bad Request: emoji 01F8MH9H8E4VG3KDYJR9EGPXCQ is not a remote emoji, cannot copy it to local"}`, string(b)) + suite.Equal(`{"error":"Bad Request: target emoji is not remote; cannot copy to local"}`, string(b)) } func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() { @@ -541,7 +542,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() { b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"Conflict: emoji with shortcode rainbow already exists on this instance"}`, string(b)) + suite.Equal(`{"error":"Conflict: emoji with shortcode already exists"}`, string(b)) } func TestEmojiUpdateTestSuite(t *testing.T) { diff --git a/internal/api/fileserver/servefile_test.go b/internal/api/fileserver/servefile_test.go index c840d232f..cb3a35e45 100644 --- a/internal/api/fileserver/servefile_test.go +++ b/internal/api/fileserver/servefile_test.go @@ -19,7 +19,7 @@ package fileserver_test import ( "context" - "io/ioutil" + "io" "net/http" "net/http/httptest" "testing" @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/fileserver" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/middleware" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -54,12 +55,15 @@ func (suite *ServeFileTestSuite) GetFile( ctx.AddParam(fileserver.MediaSizeKey, string(mediaSize)) ctx.AddParam(fileserver.FileNameKey, filename) + logger := middleware.Logger(false) suite.fileServer.ServeFile(ctx) + logger(ctx) + code = recorder.Code headers = recorder.Result().Header var err error - body, err = ioutil.ReadAll(recorder.Body) + body, err = io.ReadAll(recorder.Body) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/cache/size.go b/internal/cache/size.go index e1529f741..fb1f165c2 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -334,7 +334,6 @@ func sizeofEmoji() uintptr { ImageStaticPath: exampleURI, ImageContentType: "image/png", ImageStaticContentType: "image/png", - ImageUpdatedAt: exampleTime, Disabled: func() *bool { ok := false; return &ok }(), URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ", VisibleInPicker: func() *bool { ok := true; return &ok }(), @@ -473,12 +472,10 @@ func sizeofMedia() uintptr { File: gtsmodel.File{ Path: exampleURI, ContentType: "image/jpeg", - UpdatedAt: exampleTime, }, Thumbnail: gtsmodel.Thumbnail{ Path: exampleURI, ContentType: "image/jpeg", - UpdatedAt: exampleTime, URL: exampleURI, RemoteURL: exampleURI, }, diff --git a/internal/cleaner/media_test.go b/internal/cleaner/media_test.go index b33ae4b4f..acb5416f7 100644 --- a/internal/cleaner/media_test.go +++ b/internal/cleaner/media_test.go @@ -386,11 +386,10 @@ func (suite *MediaTestSuite) TestUncacheAndRecache() { testStatusAttachment, testHeader, } { - processingRecache, err := suite.manager.PreProcessMediaRecache(ctx, data, original.ID) - suite.NoError(err) + processing := suite.manager.RecacheMedia(original, data) // synchronously load the recached attachment - recachedAttachment, err := processingRecache.LoadAttachment(ctx) + recachedAttachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(recachedAttachment) diff --git a/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix.go b/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix.go index b6cd2ffe5..efb9f6ce6 100644 --- a/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix.go +++ b/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix.go @@ -20,7 +20,7 @@ package migrations import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix" "github.com/uptrace/bun" "github.com/uptrace/bun/dialect" ) diff --git a/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix/emoji.go b/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix/emoji.go new file mode 100644 index 000000000..2c00cd765 --- /dev/null +++ b/internal/db/bundb/migrations/20230521105850_emoji_empty_domain_fix/emoji.go @@ -0,0 +1,53 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package 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 `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Shortcode string `validate:"required" bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ eg., 'blob_hug' 'purple_heart' Must be unique with domain. + Domain string `validate:"omitempty,fqdn" bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. + ImageRemoteURL string `validate:"required_without=ImageURL,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis. + ImageStaticRemoteURL string `validate:"required_without=ImageStaticURL,omitempty,url" bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. + ImageURL string `validate:"required_without=ImageRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis. + ImageStaticURL string `validate:"required_without=ImageStaticRemoteURL,required_without=Domain,omitempty,url" bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. + ImagePath string `validate:"required,file" bun:",nullzero,notnull"` // Path of the emoji image in the server storage system. + ImageStaticPath string `validate:"required,file" bun:",nullzero,notnull"` // Path of a static version of the emoji image in the server storage system + ImageContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the emoji image + ImageStaticContentType string `validate:"required" bun:",nullzero,notnull"` // MIME content type of the static version of the emoji image. + ImageFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the emoji image file in bytes, for serving purposes. + ImageStaticFileSize int `validate:"required,min=1" bun:",nullzero,notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. + ImageUpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the emoji image last updated? + Disabled *bool `validate:"-" bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown? + URI string `validate:"url" bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234' + VisibleInPicker *bool `validate:"-" bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker? + Category *EmojiCategory `validate:"-" bun:"rel:belongs-to"` // In which emoji category is this emoji visible? + CategoryID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to. +} + +// EmojiCategory represents a grouping of custom emojis. +type EmojiCategory struct { + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Name string `validate:"required" bun:",nullzero,notnull,unique"` // name of this category +} diff --git a/internal/db/bundb/migrations/20240613091853_drop_unused_media_columns.go b/internal/db/bundb/migrations/20240613091853_drop_unused_media_columns.go new file mode 100644 index 000000000..7c0cea99e --- /dev/null +++ b/internal/db/bundb/migrations/20240613091853_drop_unused_media_columns.go @@ -0,0 +1,68 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + "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 { + + for _, dropcase := range []struct { + table string + col string + }{ + {table: "media_attachments", col: "file_updated_at"}, + {table: "media_attachments", col: "thumbnail_updated_at"}, + {table: "emojis", col: "thumbnail_updated_at"}, + } { + // For each case check the column actually exists on database. + exists, err := doesColumnExist(ctx, tx, dropcase.table, dropcase.col) + if err != nil { + return err + } + + if exists { + // Now actually drop the column. + if _, err := tx.NewDropColumn(). + Table(dropcase.table). + Column(dropcase.col). + Exec(ctx); 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) + } +} diff --git a/internal/db/bundb/migrations/util.go b/internal/db/bundb/migrations/util.go new file mode 100644 index 000000000..47de09e23 --- /dev/null +++ b/internal/db/bundb/migrations/util.go @@ -0,0 +1,40 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +// doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately. +func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) { + var n int + var err error + switch tx.Dialect().Name() { + case dialect.SQLite: + err = tx.NewRaw("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name=?", table, col).Scan(ctx, &n) + case dialect.PG: + err = tx.NewRaw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name=? and column_name=?", table, col).Scan(ctx, &n) + default: + panic("unexpected dialect") + } + return (n > 0), err +} diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 069fca1bc..e48507124 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -33,7 +33,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -730,18 +729,18 @@ func (d *Dereferencer) enrichAccount( latestAcc.ID = account.ID latestAcc.FetchedAt = time.Now() - // Ensure the account's avatar media is populated, passing in existing to check for changes. - if err := d.fetchRemoteAccountAvatar(ctx, tsport, account, latestAcc); err != nil { + // Ensure the account's avatar media is populated, passing in existing to check for chages. + if err := d.fetchAccountAvatar(ctx, requestUser, account, latestAcc); err != nil { log.Errorf(ctx, "error fetching remote avatar for account %s: %v", uri, err) } - // Ensure the account's avatar media is populated, passing in existing to check for changes. - if err := d.fetchRemoteAccountHeader(ctx, tsport, account, latestAcc); err != nil { + // Ensure the account's avatar media is populated, passing in existing to check for chages. + if err := d.fetchAccountHeader(ctx, requestUser, account, latestAcc); err != nil { log.Errorf(ctx, "error fetching remote header for account %s: %v", uri, err) } // Fetch the latest remote account emoji IDs used in account display name/bio. - if _, err = d.fetchRemoteAccountEmojis(ctx, latestAcc, requestUser); err != nil { + if err = d.fetchAccountEmojis(ctx, account, latestAcc); err != nil { log.Errorf(ctx, "error fetching remote emojis for account %s: %v", uri, err) } @@ -779,9 +778,9 @@ func (d *Dereferencer) enrichAccount( return latestAcc, apubAcc, nil } -func (d *Dereferencer) fetchRemoteAccountAvatar( +func (d *Dereferencer) fetchAccountAvatar( ctx context.Context, - tsport transport.Transport, + requestUser string, existingAcc *gtsmodel.Account, latestAcc *gtsmodel.Account, ) error { @@ -808,7 +807,7 @@ func (d *Dereferencer) fetchRemoteAccountAvatar( // Ensuring existing attachment is up-to-date // and any recaching is performed if required. existing, err := d.updateAttachment(ctx, - tsport, + requestUser, existing, nil, ) @@ -830,18 +829,23 @@ func (d *Dereferencer) fetchRemoteAccountAvatar( } } - // Fetch newly changed avatar from remote. - attachment, err := d.loadAttachment(ctx, - tsport, + // Fetch newly changed avatar. + attachment, err := d.GetMedia(ctx, + requestUser, latestAcc.ID, latestAcc.AvatarRemoteURL, - &media.AdditionalMediaInfo{ + media.AdditionalMediaInfo{ Avatar: util.Ptr(true), RemoteURL: &latestAcc.AvatarRemoteURL, }, ) if err != nil { - return gtserror.Newf("error loading attachment %s: %w", latestAcc.AvatarRemoteURL, err) + if attachment == nil { + return gtserror.Newf("error loading attachment %s: %w", latestAcc.AvatarRemoteURL, err) + } + + // non-fatal error occurred during loading, still use it. + log.Warnf(ctx, "partially loaded attachment: %v", err) } // Set the avatar attachment on account model. @@ -851,9 +855,9 @@ func (d *Dereferencer) fetchRemoteAccountAvatar( return nil } -func (d *Dereferencer) fetchRemoteAccountHeader( +func (d *Dereferencer) fetchAccountHeader( ctx context.Context, - tsport transport.Transport, + requestUser string, existingAcc *gtsmodel.Account, latestAcc *gtsmodel.Account, ) error { @@ -880,7 +884,7 @@ func (d *Dereferencer) fetchRemoteAccountHeader( // Ensuring existing attachment is up-to-date // and any recaching is performed if required. existing, err := d.updateAttachment(ctx, - tsport, + requestUser, existing, nil, ) @@ -902,18 +906,23 @@ func (d *Dereferencer) fetchRemoteAccountHeader( } } - // Fetch newly changed header from remote. - attachment, err := d.loadAttachment(ctx, - tsport, + // Fetch newly changed header. + attachment, err := d.GetMedia(ctx, + requestUser, latestAcc.ID, latestAcc.HeaderRemoteURL, - &media.AdditionalMediaInfo{ + media.AdditionalMediaInfo{ Header: util.Ptr(true), RemoteURL: &latestAcc.HeaderRemoteURL, }, ) if err != nil { - return gtserror.Newf("error loading attachment %s: %w", latestAcc.HeaderRemoteURL, err) + if attachment == nil { + return gtserror.Newf("error loading attachment %s: %w", latestAcc.HeaderRemoteURL, err) + } + + // non-fatal error occurred during loading, still use it. + log.Warnf(ctx, "partially loaded attachment: %v", err) } // Set the header attachment on account model. @@ -923,119 +932,44 @@ func (d *Dereferencer) fetchRemoteAccountHeader( return nil } -func (d *Dereferencer) fetchRemoteAccountEmojis(ctx context.Context, targetAccount *gtsmodel.Account, requestingUsername string) (bool, error) { - maybeEmojis := targetAccount.Emojis - maybeEmojiIDs := targetAccount.EmojiIDs - - // It's possible that the account had emoji IDs set on it, but not Emojis - // themselves, depending on how it was fetched before being passed to us. - // - // If we only have IDs, fetch the emojis from the db. We know they're in - // there or else they wouldn't have IDs. - if len(maybeEmojiIDs) > len(maybeEmojis) { - maybeEmojis = make([]*gtsmodel.Emoji, 0, len(maybeEmojiIDs)) - for _, emojiID := range maybeEmojiIDs { - maybeEmoji, err := d.state.DB.GetEmojiByID(ctx, emojiID) - if err != nil { - return false, err - } - maybeEmojis = append(maybeEmojis, maybeEmoji) - } - } - - // For all the maybe emojis we have, we either fetch them from the database - // (if we haven't already), or dereference them from the remote instance. - gotEmojis, err := d.populateEmojis(ctx, maybeEmojis, requestingUsername) - if err != nil { - return false, err - } - - // Extract the ID of each fetched or dereferenced emoji, so we can attach - // this to the account if necessary. - gotEmojiIDs := make([]string, 0, len(gotEmojis)) - for _, e := range gotEmojis { - gotEmojiIDs = append(gotEmojiIDs, e.ID) - } - - var ( - changed = false // have the emojis for this account changed? - maybeLen = len(maybeEmojis) - gotLen = len(gotEmojis) +func (d *Dereferencer) fetchAccountEmojis( + ctx context.Context, + existing *gtsmodel.Account, + account *gtsmodel.Account, +) error { + // Fetch the updated emojis for our account. + emojis, changed, err := d.fetchEmojis(ctx, + existing.Emojis, + account.Emojis, ) - - // if the length of everything is zero, this is simple: - // nothing has changed and there's nothing to do - if maybeLen == 0 && gotLen == 0 { - return changed, nil + if err != nil { + return gtserror.Newf("error fetching emojis: %w", err) } - // if the *amount* of emojis on the account has changed, then the got emojis - // are definitely different from the previous ones (if there were any) -- - // the account has either more or fewer emojis set on it now, so take the - // discovered emojis as the new correct ones. - if maybeLen != gotLen { - changed = true - targetAccount.Emojis = gotEmojis - targetAccount.EmojiIDs = gotEmojiIDs - return changed, nil + if !changed { + // Use existing account emoji objects. + account.EmojiIDs = existing.EmojiIDs + account.Emojis = existing.Emojis + return nil } - // if the lengths are the same but not all of the slices are - // zero, something *might* have changed, so we have to check + // Set latest emojis. + account.Emojis = emojis - // 1. did we have emojis before that we don't have now? - for _, maybeEmoji := range maybeEmojis { - var stillPresent bool - - for _, gotEmoji := range gotEmojis { - if maybeEmoji.URI == gotEmoji.URI { - // the emoji we maybe had is still present now, - // so we can stop checking gotEmojis - stillPresent = true - break - } - } - - if !stillPresent { - // at least one maybeEmoji is no longer present in - // the got emojis, so we can stop checking now - changed = true - targetAccount.Emojis = gotEmojis - targetAccount.EmojiIDs = gotEmojiIDs - return changed, nil - } + // Iterate over and set changed emoji IDs. + account.EmojiIDs = make([]string, len(emojis)) + for i, emoji := range emojis { + account.EmojiIDs[i] = emoji.ID } - // 2. do we have emojis now that we didn't have before? - for _, gotEmoji := range gotEmojis { - var wasPresent bool - - for _, maybeEmoji := range maybeEmojis { - // check emoji IDs here as well, because unreferenced - // maybe emojis we didn't already have would not have - // had IDs set on them yet - if gotEmoji.URI == maybeEmoji.URI && gotEmoji.ID == maybeEmoji.ID { - // this got emoji was present already in the maybeEmoji, - // so we can stop checking through maybeEmojis - wasPresent = true - break - } - } - - if !wasPresent { - // at least one gotEmojis was not present in - // the maybeEmojis, so we can stop checking now - changed = true - targetAccount.Emojis = gotEmojis - targetAccount.EmojiIDs = gotEmojiIDs - return changed, nil - } - } - - return changed, nil + return nil } -func (d *Dereferencer) dereferenceAccountStats(ctx context.Context, requestUser string, account *gtsmodel.Account) error { +func (d *Dereferencer) dereferenceAccountStats( + ctx context.Context, + requestUser string, + account *gtsmodel.Account, +) error { // Ensure we have a stats model for this account. if account.Stats == nil { if err := d.state.DB.PopulateAccountStats(ctx, account); err != nil { diff --git a/internal/federation/dereferencing/emoji.go b/internal/federation/dereferencing/emoji.go index e81737d04..16f5acf25 100644 --- a/internal/federation/dereferencing/emoji.go +++ b/internal/federation/dereferencing/emoji.go @@ -19,29 +19,190 @@ package dereferencing import ( "context" - "fmt" + "errors" "io" "net/url" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (d *Dereferencer) GetRemoteEmoji(ctx context.Context, requestUser string, remoteURL string, shortcode string, domain string, id string, emojiURI string, ai *media.AdditionalEmojiInfo, refresh bool) (*media.ProcessingEmoji, error) { - var shortcodeDomain = shortcode + "@" + domain - - // Ensure we have been passed a valid URL. - derefURI, err := url.Parse(remoteURL) - if err != nil { - return nil, fmt.Errorf("GetRemoteEmoji: error parsing url for emoji %s: %s", shortcodeDomain, err) +// GetEmoji fetches the emoji with given shortcode, +// domain and remote URL to dereference it by. This +// handles the case of existing emojis by passing them +// to RefreshEmoji(), which in the case of a local +// emoji will be a no-op. If the emoji does not yet +// exist it will be newly inserted into the database +// followed by dereferencing the actual media file. +// +// Please note that even if an error is returned, +// an emoji model may still be returned if the error +// was only encountered during actual dereferencing. +// In this case, it will act as a placeholder. +func (d *Dereferencer) GetEmoji( + ctx context.Context, + shortcode string, + domain string, + remoteURL string, + info media.AdditionalEmojiInfo, + refresh bool, +) ( + *gtsmodel.Emoji, + error, +) { + // Look for an existing emoji with shortcode domain. + emoji, err := d.state.DB.GetEmojiByShortcodeDomain(ctx, + shortcode, + domain, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error fetching emoji from db: %w", err) } - // Acquire derefs lock. + if emoji != nil { + // This was an existing emoji, pass to refresh func. + return d.RefreshEmoji(ctx, emoji, info, refresh) + } + + if domain == "" { + // failed local lookup, will be db.ErrNoEntries. + return nil, gtserror.SetUnretrievable(err) + } + + // Generate shortcode domain for locks + logging. + shortcodeDomain := shortcode + "@" + domain + + // Ensure we have a valid remote URL. + url, err := url.Parse(remoteURL) + if err != nil { + err := gtserror.Newf("invalid image remote url %s for emoji %s: %w", remoteURL, shortcodeDomain, err) + return nil, err + } + + // Acquire new instance account transport for emoji dereferencing. + tsport, err := d.transportController.NewTransportForUsername(ctx, "") + if err != nil { + err := gtserror.Newf("error getting instance transport: %w", err) + return nil, err + } + + // Prepare data function to dereference remote emoji media. + data := func(context.Context) (io.ReadCloser, int64, error) { + return tsport.DereferenceMedia(ctx, url) + } + + // Pass along for safe processing. + return d.processEmojiSafely(ctx, + shortcodeDomain, + func() (*media.ProcessingEmoji, error) { + return d.mediaManager.CreateEmoji(ctx, + shortcode, + domain, + data, + info, + ) + }, + ) +} + +// RefreshEmoji ensures that the given emoji is +// up-to-date, both in terms of being cached in +// in local instance storage, and compared to extra +// information provided in media.AdditionEmojiInfo{}. +// (note that is a no-op to pass in a local emoji). +// +// Please note that even if an error is returned, +// an emoji model may still be returned if the error +// was only encountered during actual dereferencing. +// In this case, it will act as a placeholder. +func (d *Dereferencer) RefreshEmoji( + ctx context.Context, + emoji *gtsmodel.Emoji, + info media.AdditionalEmojiInfo, + force bool, +) ( + *gtsmodel.Emoji, + error, +) { + // Can't refresh local. + if emoji.IsLocal() { + return emoji, nil + } + + // Check emoji is up-to-date + // with provided extra info. + switch { + case info.URI != nil && + *info.URI != emoji.URI: + force = true + case info.ImageRemoteURL != nil && + *info.ImageRemoteURL != emoji.ImageRemoteURL: + force = true + case info.ImageStaticRemoteURL != nil && + *info.ImageStaticRemoteURL != emoji.ImageStaticRemoteURL: + force = true + } + + // Check if needs updating. + if !force && *emoji.Cached { + return emoji, nil + } + + // TODO: more finegrained freshness checks. + + // Generate shortcode domain for locks + logging. + shortcodeDomain := emoji.Shortcode + "@" + emoji.Domain + + // Ensure we have a valid image remote URL. + url, err := url.Parse(emoji.ImageRemoteURL) + if err != nil { + err := gtserror.Newf("invalid image remote url %s for emoji %s: %w", emoji.ImageRemoteURL, shortcodeDomain, err) + return nil, err + } + + // Acquire new instance account transport for emoji dereferencing. + tsport, err := d.transportController.NewTransportForUsername(ctx, "") + if err != nil { + err := gtserror.Newf("error getting instance transport: %w", err) + return nil, err + } + + // Prepare data function to dereference remote emoji media. + data := func(context.Context) (io.ReadCloser, int64, error) { + return tsport.DereferenceMedia(ctx, url) + } + + // Pass along for safe processing. + return d.processEmojiSafely(ctx, + shortcodeDomain, + func() (*media.ProcessingEmoji, error) { + return d.mediaManager.RefreshEmoji(ctx, + emoji, + data, + info, + ) + }, + ) +} + +// processingEmojiSafely provides concurrency-safe processing of +// an emoji with given shortcode+domain. if a copy of the emoji is +// not already being processed, the given 'process' callback will +// be used to generate new *media.ProcessingEmoji{} instance. +func (d *Dereferencer) processEmojiSafely( + ctx context.Context, + shortcodeDomain string, + process func() (*media.ProcessingEmoji, error), +) ( + emoji *gtsmodel.Emoji, + err error, +) { + + // Acquire map lock. d.derefEmojisMu.Lock() // Ensure unlock only done once. @@ -53,146 +214,118 @@ func (d *Dereferencer) GetRemoteEmoji(ctx context.Context, requestUser string, r processing, ok := d.derefEmojis[shortcodeDomain] if !ok { - // Fetch a transport for current request user in order to perform request. - tsport, err := d.transportController.NewTransportForUsername(ctx, requestUser) + // Start new processing emoji. + processing, err = process() if err != nil { - return nil, gtserror.Newf("couldn't create transport: %w", err) + return nil, err } - - // Set the media data function to dereference emoji from URI. - data := func(ctx context.Context) (io.ReadCloser, int64, error) { - return tsport.DereferenceMedia(ctx, derefURI) - } - - // Create new emoji processing request from the media manager. - processing, err = d.mediaManager.PreProcessEmoji(ctx, data, - shortcode, - id, - emojiURI, - ai, - refresh, - ) - if err != nil { - return nil, gtserror.Newf("error preprocessing emoji %s: %s", shortcodeDomain, err) - } - - // Store media in map to mark as processing. - d.derefEmojis[shortcodeDomain] = processing - - defer func() { - // On exit safely remove emoji from map. - d.derefEmojisMu.Lock() - delete(d.derefEmojis, shortcodeDomain) - d.derefEmojisMu.Unlock() - }() } // Unlock map. unlock() - // Start emoji attachment loading (blocking call). - if _, err := processing.LoadEmoji(ctx); err != nil { - return nil, err + // Perform emoji load operation. + emoji, err = processing.Load(ctx) + if err != nil { + err = gtserror.Newf("error loading emoji %s: %w", shortcodeDomain, err) + + // TODO: in time we should return checkable flags by gtserror.Is___() + // which can determine if loading error should allow remaining placeholder. } - return processing, nil + // Return a COPY of emoji. + emoji2 := new(gtsmodel.Emoji) + *emoji2 = *emoji + return emoji2, err } -func (d *Dereferencer) populateEmojis(ctx context.Context, rawEmojis []*gtsmodel.Emoji, requestingUsername string) ([]*gtsmodel.Emoji, error) { - // At this point we should know: - // * the AP uri of the emoji - // * the domain of the emoji - // * the shortcode of the emoji - // * the remote URL of the image - // This should be enough to dereference the emoji - gotEmojis := make([]*gtsmodel.Emoji, 0, len(rawEmojis)) +func (d *Dereferencer) fetchEmojis( + ctx context.Context, + existing []*gtsmodel.Emoji, + emojis []*gtsmodel.Emoji, // newly dereferenced +) ( + []*gtsmodel.Emoji, + bool, // any changes? + error, +) { + // Track any changes. + changed := false - for _, e := range rawEmojis { - var gotEmoji *gtsmodel.Emoji - var err error - shortcodeDomain := e.Shortcode + "@" + e.Domain + for i, placeholder := range emojis { + // Look for an existing emoji with shortcode + domain. + existing, ok := getEmojiByShortcodeDomain(existing, + placeholder.Shortcode, + placeholder.Domain, + ) + if ok && existing.ID != "" { - // check if we already know this emoji - if e.ID != "" { - // we had an ID for this emoji already, which means - // it should be fleshed out already and we won't - // have to get it from the database again - gotEmoji = e - } else if gotEmoji, err = d.state.DB.GetEmojiByShortcodeDomain(ctx, e.Shortcode, e.Domain); err != nil && err != db.ErrNoEntries { - log.Errorf(ctx, "error checking database for emoji %s: %s", shortcodeDomain, err) + // Check for any emoji changes that + // indicate we should force a refresh. + force := emojiChanged(existing, placeholder) + + // Ensure that the existing emoji model is up-to-date and cached. + existing, err := d.RefreshEmoji(ctx, existing, media.AdditionalEmojiInfo{ + + // Set latest values from placeholder. + URI: &placeholder.URI, + ImageRemoteURL: &placeholder.ImageRemoteURL, + ImageStaticRemoteURL: &placeholder.ImageStaticRemoteURL, + }, force) + if err != nil { + log.Errorf(ctx, "error refreshing emoji: %v", err) + + // specifically do NOT continue here, + // we already have a model, we don't + // want to drop it from the slice, just + // log that an update for it failed. + } + + // Set existing emoji. + emojis[i] = existing continue } - var refresh bool + // Emojis changed! + changed = true - if gotEmoji != nil { - // we had the emoji already, but refresh it if necessary - if e.UpdatedAt.Unix() > gotEmoji.ImageUpdatedAt.Unix() { - log.Tracef(ctx, "emoji %s was updated since we last saw it, will refresh", shortcodeDomain) - refresh = true - } - - if !refresh && (e.URI != gotEmoji.URI) { - log.Tracef(ctx, "emoji %s changed URI since we last saw it, will refresh", shortcodeDomain) - refresh = true - } - - if !refresh && (e.ImageRemoteURL != gotEmoji.ImageRemoteURL) { - log.Tracef(ctx, "emoji %s changed image URL since we last saw it, will refresh", shortcodeDomain) - refresh = true - } - - if !refresh { - log.Tracef(ctx, "emoji %s is up to date, will not refresh", shortcodeDomain) - } else { - log.Tracef(ctx, "refreshing emoji %s", shortcodeDomain) - emojiID := gotEmoji.ID // use existing ID - processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, e.Domain, emojiID, e.URI, &media.AdditionalEmojiInfo{ - Domain: &e.Domain, - ImageRemoteURL: &e.ImageRemoteURL, - ImageStaticRemoteURL: &e.ImageStaticRemoteURL, - Disabled: gotEmoji.Disabled, - VisibleInPicker: gotEmoji.VisibleInPicker, - }, refresh) - if err != nil { - log.Errorf(ctx, "couldn't refresh remote emoji %s: %s", shortcodeDomain, err) - continue - } - - if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil { - log.Errorf(ctx, "couldn't load refreshed remote emoji %s: %s", shortcodeDomain, err) - continue - } - } - } else { - // it's new! go get it! - newEmojiID, err := id.NewRandomULID() - if err != nil { - log.Errorf(ctx, "error generating id for remote emoji %s: %s", shortcodeDomain, err) + // Fetch this newly added emoji, + // this function handles the case + // of existing cached emojis and + // new ones requiring dereference. + emoji, err := d.GetEmoji(ctx, + placeholder.Shortcode, + placeholder.Domain, + placeholder.ImageRemoteURL, + media.AdditionalEmojiInfo{ + URI: &placeholder.URI, + ImageRemoteURL: &placeholder.ImageRemoteURL, + ImageStaticRemoteURL: &placeholder.ImageStaticRemoteURL, + }, + false, + ) + if err != nil { + if emoji == nil { + log.Errorf(ctx, "error loading emoji %s: %v", placeholder.ImageRemoteURL, err) continue } - processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, e.Domain, newEmojiID, e.URI, &media.AdditionalEmojiInfo{ - Domain: &e.Domain, - ImageRemoteURL: &e.ImageRemoteURL, - ImageStaticRemoteURL: &e.ImageStaticRemoteURL, - Disabled: e.Disabled, - VisibleInPicker: e.VisibleInPicker, - }, refresh) - if err != nil { - log.Errorf(ctx, "couldn't get remote emoji %s: %s", shortcodeDomain, err) - continue - } - - if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil { - log.Errorf(ctx, "couldn't load remote emoji %s: %s", shortcodeDomain, err) - continue - } + // non-fatal error occurred during loading, still use it. + log.Warnf(ctx, "partially loaded emoji: %v", err) } - // if we get here, we either had the emoji already or we successfully fetched it - gotEmojis = append(gotEmojis, gotEmoji) + // Set updated emoji. + emojis[i] = emoji } - return gotEmojis, nil + for i := 0; i < len(emojis); { + if emojis[i].ID == "" { + // Remove failed emoji populations. + copy(emojis[i:], emojis[i+1:]) + emojis = emojis[:len(emojis)-1] + continue + } + i++ + } + + return emojis, changed, nil } diff --git a/internal/federation/dereferencing/emoji_test.go b/internal/federation/dereferencing/emoji_test.go index 08365741f..fdb815762 100644 --- a/internal/federation/dereferencing/emoji_test.go +++ b/internal/federation/dereferencing/emoji_test.go @@ -19,6 +19,7 @@ package dereferencing_test import ( "context" + "fmt" "testing" "time" @@ -32,48 +33,50 @@ type EmojiTestSuite struct { func (suite *EmojiTestSuite) TestDereferenceEmojiBlocking() { ctx := context.Background() - fetchingAccount := suite.testAccounts["local_account_1"] emojiImageRemoteURL := "http://example.org/media/emojis/1781772.gif" emojiImageStaticRemoteURL := "http://example.org/media/emojis/1781772.gif" emojiURI := "http://example.org/emojis/1781772" emojiShortcode := "peglin" - emojiID := "01GCBMGNZBKMEE1KTZ6PMJEW5D" emojiDomain := "example.org" emojiDisabled := false emojiVisibleInPicker := false - ai := &media.AdditionalEmojiInfo{ - Domain: &emojiDomain, - ImageRemoteURL: &emojiImageRemoteURL, - ImageStaticRemoteURL: &emojiImageStaticRemoteURL, - Disabled: &emojiDisabled, - VisibleInPicker: &emojiVisibleInPicker, - } - - processingEmoji, err := suite.dereferencer.GetRemoteEmoji(ctx, fetchingAccount.Username, emojiImageRemoteURL, emojiShortcode, emojiDomain, emojiID, emojiURI, ai, false) - suite.NoError(err) - - // make a blocking call to load the emoji from the in-process media - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := suite.dereferencer.GetEmoji( + ctx, + emojiShortcode, + emojiDomain, + emojiImageRemoteURL, + media.AdditionalEmojiInfo{ + URI: &emojiURI, + Domain: &emojiDomain, + ImageRemoteURL: &emojiImageRemoteURL, + ImageStaticRemoteURL: &emojiImageStaticRemoteURL, + Disabled: &emojiDisabled, + VisibleInPicker: &emojiVisibleInPicker, + }, + false, + ) suite.NoError(err) suite.NotNil(emoji) - suite.Equal(emojiID, emoji.ID) + expectPath := fmt.Sprintf("/emoji/original/%s.gif", emoji.ID) + expectStaticPath := fmt.Sprintf("/emoji/static/%s.png", emoji.ID) + suite.WithinDuration(time.Now(), emoji.CreatedAt, 10*time.Second) suite.WithinDuration(time.Now(), emoji.UpdatedAt, 10*time.Second) suite.Equal(emojiShortcode, emoji.Shortcode) suite.Equal(emojiDomain, emoji.Domain) suite.Equal(emojiImageRemoteURL, emoji.ImageRemoteURL) suite.Equal(emojiImageStaticRemoteURL, emoji.ImageStaticRemoteURL) - suite.Contains(emoji.ImageURL, "/emoji/original/01GCBMGNZBKMEE1KTZ6PMJEW5D.gif") - suite.Contains(emoji.ImageStaticURL, "emoji/static/01GCBMGNZBKMEE1KTZ6PMJEW5D.png") - suite.Contains(emoji.ImagePath, "/emoji/original/01GCBMGNZBKMEE1KTZ6PMJEW5D.gif") - suite.Contains(emoji.ImageStaticPath, "/emoji/static/01GCBMGNZBKMEE1KTZ6PMJEW5D.png") + suite.Contains(emoji.ImageURL, expectPath) + suite.Contains(emoji.ImageStaticURL, expectStaticPath) + suite.Contains(emoji.ImagePath, expectPath) + suite.Contains(emoji.ImageStaticPath, expectStaticPath) suite.Equal("image/gif", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(37796, emoji.ImageFileSize) suite.Equal(7951, emoji.ImageStaticFileSize) - suite.WithinDuration(time.Now(), emoji.ImageUpdatedAt, 10*time.Second) + suite.WithinDuration(time.Now(), emoji.UpdatedAt, 10*time.Second) suite.False(*emoji.Disabled) suite.Equal(emojiURI, emoji.URI) suite.False(*emoji.VisibleInPicker) diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go new file mode 100644 index 000000000..874107b13 --- /dev/null +++ b/internal/federation/dereferencing/media.go @@ -0,0 +1,215 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package dereferencing + +import ( + "context" + "io" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// GetMedia fetches the media at given remote URL by +// dereferencing it. The passed accountID is used to +// store it as being owned by that account. Additional +// information to set on the media attachment may also +// be provided. +// +// Please note that even if an error is returned, +// a media model may still be returned if the error +// was only encountered during actual dereferencing. +// In this case, it will act as a placeholder. +// +// Also note that since account / status dereferencing is +// already protected by per-uri locks, and that fediverse +// media is generally not shared between accounts (etc), +// there aren't any concurrency protections against multiple +// insertion / dereferencing of media at remoteURL. Worst +// case scenario, an extra media entry will be inserted +// and the scheduled cleaner.Cleaner{} will catch it! +func (d *Dereferencer) GetMedia( + ctx context.Context, + requestUser string, + accountID string, // media account owner + remoteURL string, + info media.AdditionalMediaInfo, +) ( + *gtsmodel.MediaAttachment, + error, +) { + // Parse str as valid URL object. + url, err := url.Parse(remoteURL) + if err != nil { + return nil, gtserror.Newf("invalid remote media url %q: %v", remoteURL, err) + } + + // Fetch transport for the provided request user from controller. + tsport, err := d.transportController.NewTransportForUsername(ctx, + requestUser, + ) + if err != nil { + return nil, gtserror.Newf("failed getting transport for %s: %w", requestUser, err) + } + + // Start processing remote attachment at URL. + processing, err := d.mediaManager.CreateMedia( + ctx, + accountID, + func(ctx context.Context) (io.ReadCloser, int64, error) { + return tsport.DereferenceMedia(ctx, url) + }, + info, + ) + if err != nil { + return nil, err + } + + // Perform media load operation. + media, err := processing.Load(ctx) + if err != nil { + err = gtserror.Newf("error loading media %s: %w", media.RemoteURL, err) + + // TODO: in time we should return checkable flags by gtserror.Is___() + // which can determine if loading error should allow remaining placeholder. + } + + return media, err +} + +// RefreshMedia ensures that given media is up-to-date, +// both in terms of being cached in local instance, +// storage and compared to extra info in information +// in given gtsmodel.AdditionMediaInfo{}. This handles +// the case of local emoji by returning early. +// +// Please note that even if an error is returned, +// a media model may still be returned if the error +// was only encountered during actual dereferencing. +// In this case, it will act as a placeholder. +// +// Also note that since account / status dereferencing is +// already protected by per-uri locks, and that fediverse +// media is generally not shared between accounts (etc), +// there aren't any concurrency protections against multiple +// insertion / dereferencing of media at remoteURL. Worst +// case scenario, an extra media entry will be inserted +// and the scheduled cleaner.Cleaner{} will catch it! +func (d *Dereferencer) RefreshMedia( + ctx context.Context, + requestUser string, + media *gtsmodel.MediaAttachment, + info media.AdditionalMediaInfo, + force bool, +) ( + *gtsmodel.MediaAttachment, + error, +) { + // Can't refresh local. + if media.IsLocal() { + return media, nil + } + + // Check emoji is up-to-date + // with provided extra info. + switch { + case info.Blurhash != nil && + *info.Blurhash != media.Blurhash: + force = true + case info.Description != nil && + *info.Description != media.Description: + force = true + case info.RemoteURL != nil && + *info.RemoteURL != media.RemoteURL: + force = true + } + + // Check if needs updating. + if !force && *media.Cached { + return media, nil + } + + // TODO: more finegrained freshness checks. + + // Ensure we have a valid remote URL. + url, err := url.Parse(media.RemoteURL) + if err != nil { + err := gtserror.Newf("invalid media remote url %s: %w", media.RemoteURL, err) + return nil, err + } + + // Fetch transport for the provided request user from controller. + tsport, err := d.transportController.NewTransportForUsername(ctx, + requestUser, + ) + if err != nil { + return nil, gtserror.Newf("failed getting transport for %s: %w", requestUser, err) + } + + // Start processing remote attachment recache. + processing := d.mediaManager.RecacheMedia( + media, + func(ctx context.Context) (io.ReadCloser, int64, error) { + return tsport.DereferenceMedia(ctx, url) + }, + ) + + // Perform media load operation. + media, err = processing.Load(ctx) + if err != nil { + err = gtserror.Newf("error loading media %s: %w", media.RemoteURL, err) + + // TODO: in time we should return checkable flags by gtserror.Is___() + // which can determine if loading error should allow remaining placeholder. + } + + return media, err +} + +// updateAttachment handles the case of an existing media attachment +// that *may* have changes or need recaching. it checks for changed +// fields, updating in the database if so, and recaches uncached media. +func (d *Dereferencer) updateAttachment( + ctx context.Context, + requestUser string, + existing *gtsmodel.MediaAttachment, // existing attachment + attach *gtsmodel.MediaAttachment, // (optional) changed media +) ( + *gtsmodel.MediaAttachment, // always set + error, +) { + var info media.AdditionalMediaInfo + + if attach != nil { + // Set optional extra information, + // (will later check for changes). + info.Description = &attach.Description + info.Blurhash = &attach.Blurhash + info.RemoteURL = &attach.RemoteURL + } + + // Ensure media is cached. + return d.RefreshMedia(ctx, + requestUser, + existing, + info, + false, + ) +} diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index add12c31f..406534457 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -33,7 +33,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -536,12 +535,12 @@ func (d *Dereferencer) enrichStatus( } // Ensure the status' media attachments are populated, passing in existing to check for changes. - if err := d.fetchStatusAttachments(ctx, tsport, status, latestStatus); err != nil { + if err := d.fetchStatusAttachments(ctx, requestUser, status, latestStatus); err != nil { return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err) } - // Ensure the status' emoji attachments are populated, (changes are expected / okay). - if err := d.fetchStatusEmojis(ctx, requestUser, latestStatus); err != nil { + // Ensure the status' emoji attachments are populated, passing in existing to check for changes. + if err := d.fetchStatusEmojis(ctx, status, latestStatus); err != nil { return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err) } @@ -643,79 +642,12 @@ func (d *Dereferencer) isPermittedStatus( return onFail() } -// populateMentionTarget tries to populate the given -// mention with the correct TargetAccount and (if not -// yet set) TargetAccountURI, returning the populated -// mention. -// -// Will check on the existing status if the mention -// is already there and populated; if so, existing -// mention will be returned along with `true`. -// -// Otherwise, this function will try to parse first -// the Href of the mention, and then the namestring, -// to see who it targets, and go fetch that account. -func (d *Dereferencer) populateMentionTarget( +func (d *Dereferencer) fetchStatusMentions( ctx context.Context, - mention *gtsmodel.Mention, requestUser string, - existing, status *gtsmodel.Status, -) ( - *gtsmodel.Mention, - bool, // True if mention already exists in the DB. - error, -) { - // Mentions can be created using Name or Href. - // Prefer Href (TargetAccountURI), fall back to Name. - if mention.TargetAccountURI != "" { - // Look for existing mention with this URI. - // If we already have it we can return early. - existingMention, ok := existing.GetMentionByTargetURI(mention.TargetAccountURI) - if ok && existingMention.ID != "" { - return existingMention, true, nil - } - - // Ensure that mention account URI is parseable. - accountURI, err := url.Parse(mention.TargetAccountURI) - if err != nil { - err = gtserror.Newf("invalid account uri %q: %w", mention.TargetAccountURI, err) - return nil, false, err - } - - // Ensure we have the account of the mention target dereferenced. - mention.TargetAccount, _, err = d.getAccountByURI(ctx, requestUser, accountURI) - if err != nil { - err = gtserror.Newf("failed to dereference account %s: %w", accountURI, err) - return nil, false, err - } - } else { - // Href wasn't set. Find the target account using namestring. - username, domain, err := util.ExtractNamestringParts(mention.NameString) - if err != nil { - err = gtserror.Newf("failed to parse namestring %s: %w", mention.NameString, err) - return nil, false, err - } - - mention.TargetAccount, _, err = d.getAccountByUsernameDomain(ctx, requestUser, username, domain) - if err != nil { - err = gtserror.Newf("failed to dereference account %s: %w", mention.NameString, err) - return nil, false, err - } - - // Look for existing mention with this URI. - mention.TargetAccountURI = mention.TargetAccount.URI - existingMention, ok := existing.GetMentionByTargetURI(mention.TargetAccountURI) - if ok && existingMention.ID != "" { - return existingMention, true, nil - } - } - - // At this point, mention.TargetAccountURI - // and mention.TargetAccount must be set. - return mention, false, nil -} - -func (d *Dereferencer) fetchStatusMentions(ctx context.Context, requestUser string, existing, status *gtsmodel.Status) error { + existing *gtsmodel.Status, + status *gtsmodel.Status, +) error { // Allocate new slice to take the yet-to-be created mention IDs. status.MentionIDs = make([]string, len(status.Mentions)) @@ -728,10 +660,10 @@ func (d *Dereferencer) fetchStatusMentions(ctx context.Context, requestUser stri mention, alreadyExists, err = d.populateMentionTarget( ctx, - mention, requestUser, existing, status, + mention, ) if err != nil { log.Errorf(ctx, "failed to derive mention: %v", err) @@ -845,7 +777,11 @@ func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status return nil } -func (d *Dereferencer) fetchStatusTags(ctx context.Context, existing, status *gtsmodel.Status) error { +func (d *Dereferencer) fetchStatusTags( + ctx context.Context, + existing *gtsmodel.Status, + status *gtsmodel.Status, +) error { // Allocate new slice to take the yet-to-be determined tag IDs. status.TagIDs = make([]string, len(status.Tags)) @@ -900,7 +836,11 @@ func (d *Dereferencer) fetchStatusTags(ctx context.Context, existing, status *gt return nil } -func (d *Dereferencer) fetchStatusPoll(ctx context.Context, existing, status *gtsmodel.Status) error { +func (d *Dereferencer) fetchStatusPoll( + ctx context.Context, + existing *gtsmodel.Status, + status *gtsmodel.Status, +) error { var ( // insertStatusPoll generates ID and inserts the poll attached to status into the database. insertStatusPoll = func(ctx context.Context, status *gtsmodel.Status) error { @@ -990,19 +930,24 @@ func (d *Dereferencer) fetchStatusPoll(ctx context.Context, existing, status *gt } } -func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transport.Transport, existing, status *gtsmodel.Status) error { +func (d *Dereferencer) fetchStatusAttachments( + ctx context.Context, + requestUser string, + existing *gtsmodel.Status, + status *gtsmodel.Status, +) error { // Allocate new slice to take the yet-to-be fetched attachment IDs. status.AttachmentIDs = make([]string, len(status.Attachments)) for i := range status.Attachments { - attachment := status.Attachments[i] + placeholder := status.Attachments[i] // Look for existing media attachment with remote URL first. - existing, ok := existing.GetAttachmentByRemoteURL(attachment.RemoteURL) + existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL) if ok && existing.ID != "" { // Ensure the existing media attachment is up-to-date and cached. - existing, err := d.updateAttachment(ctx, tsport, existing, attachment) + existing, err := d.updateAttachment(ctx, requestUser, existing, placeholder) if err != nil { log.Errorf(ctx, "error updating existing attachment: %v", err) @@ -1019,25 +964,25 @@ func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transp } // Load this new media attachment. - attachment, err := d.loadAttachment( + attachment, err := d.GetMedia( ctx, - tsport, + requestUser, status.AccountID, - attachment.RemoteURL, - &media.AdditionalMediaInfo{ + placeholder.RemoteURL, + media.AdditionalMediaInfo{ StatusID: &status.ID, - RemoteURL: &attachment.RemoteURL, - Description: &attachment.Description, - Blurhash: &attachment.Blurhash, + RemoteURL: &placeholder.RemoteURL, + Description: &placeholder.Description, + Blurhash: &placeholder.Blurhash, }, ) - if err != nil && attachment == nil { - log.Errorf(ctx, "error loading attachment: %v", err) - continue - } - if err != nil { - // A non-fatal error occurred during loading. + if attachment == nil { + log.Errorf(ctx, "error loading attachment %s: %v", placeholder.RemoteURL, err) + continue + } + + // non-fatal error occurred during loading, still use it. log.Warnf(ctx, "partially loaded attachment: %v", err) } @@ -1061,22 +1006,108 @@ func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transp return nil } -func (d *Dereferencer) fetchStatusEmojis(ctx context.Context, requestUser string, status *gtsmodel.Status) error { - // Fetch the full-fleshed-out emoji objects for our status. - emojis, err := d.populateEmojis(ctx, status.Emojis, requestUser) +func (d *Dereferencer) fetchStatusEmojis( + ctx context.Context, + existing *gtsmodel.Status, + status *gtsmodel.Status, +) error { + // Fetch the updated emojis for our status. + emojis, changed, err := d.fetchEmojis(ctx, + existing.Emojis, + status.Emojis, + ) if err != nil { - return gtserror.Newf("failed to populate emojis: %w", err) + return gtserror.Newf("error fetching emojis: %w", err) } - // Iterate over and get their IDs. - emojiIDs := make([]string, 0, len(emojis)) - for _, e := range emojis { - emojiIDs = append(emojiIDs, e.ID) + if !changed { + // Use existing status emoji objects. + status.EmojiIDs = existing.EmojiIDs + status.Emojis = existing.Emojis + return nil } - // Set known emoji details. + // Set latest emojis. status.Emojis = emojis - status.EmojiIDs = emojiIDs + + // Iterate over and set changed emoji IDs. + status.EmojiIDs = make([]string, len(emojis)) + for i, emoji := range emojis { + status.EmojiIDs[i] = emoji.ID + } return nil } + +// populateMentionTarget tries to populate the given +// mention with the correct TargetAccount and (if not +// yet set) TargetAccountURI, returning the populated +// mention. +// +// Will check on the existing status if the mention +// is already there and populated; if so, existing +// mention will be returned along with `true`. +// +// Otherwise, this function will try to parse first +// the Href of the mention, and then the namestring, +// to see who it targets, and go fetch that account. +func (d *Dereferencer) populateMentionTarget( + ctx context.Context, + requestUser string, + existing *gtsmodel.Status, + status *gtsmodel.Status, + mention *gtsmodel.Mention, +) ( + *gtsmodel.Mention, + bool, // True if mention already exists in the DB. + error, +) { + // Mentions can be created using Name or Href. + // Prefer Href (TargetAccountURI), fall back to Name. + if mention.TargetAccountURI != "" { + // Look for existing mention with this URI. + // If we already have it we can return early. + existingMention, ok := existing.GetMentionByTargetURI(mention.TargetAccountURI) + if ok && existingMention.ID != "" { + return existingMention, true, nil + } + + // Ensure that mention account URI is parseable. + accountURI, err := url.Parse(mention.TargetAccountURI) + if err != nil { + err = gtserror.Newf("invalid account uri %q: %w", mention.TargetAccountURI, err) + return nil, false, err + } + + // Ensure we have the account of the mention target dereferenced. + mention.TargetAccount, _, err = d.getAccountByURI(ctx, requestUser, accountURI) + if err != nil { + err = gtserror.Newf("failed to dereference account %s: %w", accountURI, err) + return nil, false, err + } + } else { + // Href wasn't set. Find the target account using namestring. + username, domain, err := util.ExtractNamestringParts(mention.NameString) + if err != nil { + err = gtserror.Newf("failed to parse namestring %s: %w", mention.NameString, err) + return nil, false, err + } + + mention.TargetAccount, _, err = d.getAccountByUsernameDomain(ctx, requestUser, username, domain) + if err != nil { + err = gtserror.Newf("failed to dereference account %s: %w", mention.NameString, err) + return nil, false, err + } + + // Look for existing mention with this URI. + mention.TargetAccountURI = mention.TargetAccount.URI + existingMention, ok := existing.GetMentionByTargetURI(mention.TargetAccountURI) + if ok && existingMention.ID != "" { + return existingMention, true, nil + } + } + + // At this point, mention.TargetAccountURI + // and mention.TargetAccount must be set. + return mention, false, nil +} diff --git a/internal/federation/dereferencing/util.go b/internal/federation/dereferencing/util.go index 5cb7a0106..297e90adc 100644 --- a/internal/federation/dereferencing/util.go +++ b/internal/federation/dereferencing/util.go @@ -18,120 +18,36 @@ package dereferencing import ( - "context" - "io" - "net/url" "slices" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/util" ) -// loadAttachment handles the case of a new media attachment -// that requires loading. it stores and caches from given data. -func (d *Dereferencer) loadAttachment( - ctx context.Context, - tsport transport.Transport, - accountID string, // media account owner - remoteURL string, - info *media.AdditionalMediaInfo, +// getEmojiByShortcodeDomain searches input slice +// for emoji with given shortcode and domain. +func getEmojiByShortcodeDomain( + emojis []*gtsmodel.Emoji, + shortcode string, + domain string, ) ( - *gtsmodel.MediaAttachment, - error, + *gtsmodel.Emoji, + bool, ) { - // Parse str as valid URL object. - url, err := url.Parse(remoteURL) - if err != nil { - return nil, gtserror.Newf("invalid remote media url %q: %v", remoteURL, err) + for _, emoji := range emojis { + if emoji.Shortcode == shortcode && + emoji.Domain == domain { + return emoji, true + } } - - // Start pre-processing remote media at remote URL. - processing := d.mediaManager.PreProcessMedia( - func(ctx context.Context) (io.ReadCloser, int64, error) { - return tsport.DereferenceMedia(ctx, url) - }, - accountID, - info, - ) - - // Force attachment loading *right now*. - return processing.LoadAttachment(ctx) + return nil, false } -// updateAttachment handles the case of an existing media attachment -// that *may* have changes or need recaching. it checks for changed -// fields, updating in the database if so, and recaches uncached media. -func (d *Dereferencer) updateAttachment( - ctx context.Context, - tsport transport.Transport, - existing *gtsmodel.MediaAttachment, // existing attachment - media *gtsmodel.MediaAttachment, // (optional) changed media -) ( - *gtsmodel.MediaAttachment, // always set - error, -) { - if media != nil { - // Possible changed media columns. - changed := make([]string, 0, 3) - - // Check if attachment description has changed. - if existing.Description != media.Description { - changed = append(changed, "description") - existing.Description = media.Description - } - - // Check if attachment blurhash has changed (i.e. content change). - if existing.Blurhash != media.Blurhash && media.Blurhash != "" { - changed = append(changed, "blurhash", "cached") - existing.Blurhash = media.Blurhash - existing.Cached = util.Ptr(false) - } - - if len(changed) > 0 { - // Update the existing attachment model in the database. - err := d.state.DB.UpdateAttachment(ctx, existing, changed...) - if err != nil { - return media, gtserror.Newf("error updating media: %w", err) - } - } - } - - // Check if cached. - if *existing.Cached { - return existing, nil - } - - // Parse str as valid URL object. - url, err := url.Parse(existing.RemoteURL) - if err != nil { - return nil, gtserror.Newf("invalid remote media url %q: %v", media.RemoteURL, err) - } - - // Start pre-processing remote media recaching from remote. - processing, err := d.mediaManager.PreProcessMediaRecache( - ctx, - func(ctx context.Context) (io.ReadCloser, int64, error) { - return tsport.DereferenceMedia(ctx, url) - }, - existing.ID, - ) - if err != nil { - return nil, gtserror.Newf("error processing recache: %w", err) - } - - // Force load attachment recache *right now*. - recached, err := processing.LoadAttachment(ctx) - - // Always return the error we - // receive, but ensure we return - // most up-to-date media file. - if recached != nil { - return recached, err - } - return existing, err +// emojiChanged returns whether an emoji has changed in a way +// that indicates that it should be refetched and refreshed. +func emojiChanged(existing, latest *gtsmodel.Emoji) bool { + return existing.URI != latest.URI || + existing.ImageRemoteURL != latest.ImageRemoteURL || + existing.ImageStaticRemoteURL != latest.ImageStaticRemoteURL } // pollChanged returns whether a poll has changed in way that diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go index b377b1440..c80e98ecb 100644 --- a/internal/gtsmodel/emoji.go +++ b/internal/gtsmodel/emoji.go @@ -30,19 +30,18 @@ type Emoji struct { 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:",nullzero,notnull"` // Path of the emoji image in the server storage system. - ImageStaticPath string `bun:",nullzero,notnull"` // Path of a static version of the emoji image in the server storage system - ImageContentType string `bun:",nullzero,notnull"` // MIME content type of the emoji image - ImageStaticContentType string `bun:",nullzero,notnull"` // MIME content type of the static version of the emoji image. - ImageFileSize int `bun:",nullzero,notnull"` // Size of the emoji image file in bytes, for serving purposes. - ImageStaticFileSize int `bun:",nullzero,notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. - ImageUpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the emoji image last updated? + 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"` + Cached *bool `bun:",nullzero,notnull,default:false"` // whether emoji is cached in locally in gotosocial storage. } // IsLocal returns true if the emoji is diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go index f18589f85..471a5abd1 100644 --- a/internal/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -30,8 +30,8 @@ type MediaAttachment struct { StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media) - Type FileType `bun:",nullzero,notnull"` // Type of file (image/gifv/audio/video/unknown) - FileMeta FileMeta `bun:",embed:,nullzero,notnull"` // Metadata about the file + Type FileType `bun:",notnull"` // 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 @@ -44,22 +44,30 @@ type MediaAttachment struct { Cached *bool `bun:",nullzero,notnull,default:false"` // Is this attachment currently cached by our instance? } +// IsLocal returns whether media attachment is local. +func (m *MediaAttachment) IsLocal() bool { + return m.RemoteURL == "" +} + +// IsRemote returns whether media attachment is remote. +func (m *MediaAttachment) IsRemote() bool { + return m.RemoteURL != "" +} + // File refers to the metadata for the whole file type File struct { - Path string `bun:",nullzero,notnull"` // Path of the file in storage. - ContentType string `bun:",nullzero,notnull"` // MIME content type of the file. - FileSize int `bun:",notnull"` // File size in bytes - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the file last updated. + 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:",nullzero,notnull"` // Path of the file in storage. - ContentType string `bun:",nullzero,notnull"` // MIME content type of the file. - FileSize int `bun:",notnull"` // File size in bytes - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // When was the file last updated. - 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) + 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. diff --git a/internal/media/image.go b/internal/media/image.go index 29527c085..8a34e5062 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -43,12 +43,9 @@ var ( BufferPool: &pngEncoderBufferPool{}, } - // jpegBufferPool is a memory pool of byte buffers for JPEG encoding. - jpegBufferPool = sync.Pool{ - New: func() any { - return bufio.NewWriter(nil) - }, - } + // jpegBufferPool is a memory pool + // of byte buffers for JPEG encoding. + jpegBufferPool sync.Pool ) // gtsImage is a thin wrapper around the standard library image @@ -80,25 +77,29 @@ func decodeImage(r io.Reader, opts ...imaging.DecodeOption) (*gtsImage, error) { } // Width returns the image width in pixels. -func (m *gtsImage) Width() uint32 { - return uint32(m.image.Bounds().Size().X) +func (m *gtsImage) Width() int { + return m.image.Bounds().Size().X } // Height returns the image height in pixels. -func (m *gtsImage) Height() uint32 { - return uint32(m.image.Bounds().Size().Y) +func (m *gtsImage) Height() int { + return m.image.Bounds().Size().Y } // Size returns the total number of image pixels. -func (m *gtsImage) Size() uint64 { - return uint64(m.image.Bounds().Size().X) * - uint64(m.image.Bounds().Size().Y) +func (m *gtsImage) Size() int { + return m.image.Bounds().Size().X * + m.image.Bounds().Size().Y } // AspectRatio returns the image ratio of width:height. func (m *gtsImage) AspectRatio() float32 { - return float32(m.image.Bounds().Size().X) / - float32(m.image.Bounds().Size().Y) + + // note: we cast bounds to float64 to prevent truncation + // and only at the end aspect ratio do we cast to float32 + // (as the sizes are likely to be much larger than ratio). + return float32(float64(m.image.Bounds().Size().X) / + float64(m.image.Bounds().Size().Y)) } // Thumbnail returns a small sized copy of gtsImage{}, limited to 512x512 if not small enough. @@ -160,7 +161,11 @@ func (m *gtsImage) ToPNG() io.Reader { // getJPEGBuffer fetches a reset JPEG encoding buffer from global JPEG buffer pool. func getJPEGBuffer(w io.Writer) *bufio.Writer { - buf, _ := jpegBufferPool.Get().(*bufio.Writer) + v := jpegBufferPool.Get() + if v == nil { + v = bufio.NewWriter(nil) + } + buf := v.(*bufio.Writer) buf.Reset(w) return buf } diff --git a/internal/media/manager.go b/internal/media/manager.go index be428aa3b..90a2923b5 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -56,176 +56,172 @@ func NewManager(state *state.State) *Manager { return &Manager{state: state} } -// PreProcessMedia begins the process of decoding -// and storing the given data as an attachment. -// It will return a pointer to a ProcessingMedia -// struct upon which further actions can be performed, -// such as getting the finished media, thumbnail, -// attachment, etc. -// -// - data: a function that the media manager can call -// to return a reader containing the media data. -// - accountID: the account that the media belongs to. -// - ai: optional and can be nil. Any additional information -// about the attachment provided will be put in the database. -// -// Note: unlike ProcessMedia, this will NOT -// queue the media to be asynchronously processed. -func (m *Manager) PreProcessMedia( - data DataFunc, +// CreateMedia creates a new media attachment entry +// in the database for given owning account ID and +// extra information, and prepares a new processing +// media entry to dereference it using the given +// data function, decode the media and finish filling +// out remaining media fields (e.g. type, path, etc). +func (m *Manager) CreateMedia( + ctx context.Context, accountID string, - ai *AdditionalMediaInfo, -) *ProcessingMedia { + data DataFunc, + info AdditionalMediaInfo, +) ( + *ProcessingMedia, + error, +) { + now := time.Now() + + // Generate new ID. + id := id.NewULID() + + // Placeholder URL for attachment. + url := uris.URIForAttachment( + accountID, + string(TypeAttachment), + string(SizeOriginal), + id, + "unknown", + ) + + // Placeholder storage path for attachment. + path := uris.StoragePathForAttachment( + accountID, + string(TypeAttachment), + string(SizeOriginal), + id, + "unknown", + ) + + // Calculate attachment thumbnail file path + thumbPath := uris.StoragePathForAttachment( + accountID, + string(TypeAttachment), + string(SizeSmall), + id, + + // Always encode attachment + // thumbnails as jpg. + "jpg", + ) + + // Calculate attachment thumbnail URL. + thumbURL := uris.URIForAttachment( + accountID, + string(TypeAttachment), + string(SizeSmall), + id, + + // Always encode attachment + // thumbnails as jpg. + "jpg", + ) + // Populate initial fields on the new media, // leaving out fields with values we don't know // yet. These will be overwritten as we go. - now := time.Now() attachment := >smodel.MediaAttachment{ - ID: id.NewULID(), + ID: id, CreatedAt: now, UpdatedAt: now, + URL: url, Type: gtsmodel.FileTypeUnknown, - FileMeta: gtsmodel.FileMeta{}, AccountID: accountID, Processing: gtsmodel.ProcessingStatusReceived, File: gtsmodel.File{ - UpdatedAt: now, ContentType: "application/octet-stream", + Path: path, }, - Thumbnail: gtsmodel.Thumbnail{UpdatedAt: now}, - Avatar: util.Ptr(false), - Header: util.Ptr(false), - Cached: util.Ptr(false), + Thumbnail: gtsmodel.Thumbnail{ + ContentType: mimeImageJpeg, // thumbs always jpg. + Path: thumbPath, + URL: thumbURL, + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(false), } - attachment.URL = uris.URIForAttachment( - accountID, - string(TypeAttachment), - string(SizeOriginal), - attachment.ID, - "unknown", - ) - - attachment.File.Path = uris.StoragePathForAttachment( - accountID, - string(TypeAttachment), - string(SizeOriginal), - attachment.ID, - "unknown", - ) - // Check if we were provided additional info // to add to the attachment, and overwrite // some of the attachment fields if so. - if ai != nil { - if ai.CreatedAt != nil { - attachment.CreatedAt = *ai.CreatedAt - } - - if ai.StatusID != nil { - attachment.StatusID = *ai.StatusID - } - - if ai.RemoteURL != nil { - attachment.RemoteURL = *ai.RemoteURL - } - - if ai.Description != nil { - attachment.Description = *ai.Description - } - - if ai.ScheduledStatusID != nil { - attachment.ScheduledStatusID = *ai.ScheduledStatusID - } - - if ai.Blurhash != nil { - attachment.Blurhash = *ai.Blurhash - } - - if ai.Avatar != nil { - attachment.Avatar = ai.Avatar - } - - if ai.Header != nil { - attachment.Header = ai.Header - } - - if ai.FocusX != nil { - attachment.FileMeta.Focus.X = *ai.FocusX - } - - if ai.FocusY != nil { - attachment.FileMeta.Focus.Y = *ai.FocusY - } + if info.CreatedAt != nil { + attachment.CreatedAt = *info.CreatedAt + } + if info.StatusID != nil { + attachment.StatusID = *info.StatusID + } + if info.RemoteURL != nil { + attachment.RemoteURL = *info.RemoteURL + } + if info.Description != nil { + attachment.Description = *info.Description + } + if info.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *info.ScheduledStatusID + } + if info.Blurhash != nil { + attachment.Blurhash = *info.Blurhash + } + if info.Avatar != nil { + attachment.Avatar = info.Avatar + } + if info.Header != nil { + attachment.Header = info.Header + } + if info.FocusX != nil { + attachment.FileMeta.Focus.X = *info.FocusX + } + if info.FocusY != nil { + attachment.FileMeta.Focus.Y = *info.FocusY } - processingMedia := &ProcessingMedia{ - media: attachment, - dataFn: data, - mgr: m, - } - - return processingMedia -} - -// PreProcessMediaRecache refetches, reprocesses, -// and recaches an existing attachment that has -// been uncached via cleaner pruning. -// -// Note: unlike ProcessMedia, this will NOT queue -// the media to be asychronously processed. -func (m *Manager) PreProcessMediaRecache( - ctx context.Context, - data DataFunc, - attachmentID string, -) (*ProcessingMedia, error) { - // Get the existing attachment from database. - attachment, err := m.state.DB.GetAttachmentByID(ctx, attachmentID) + // Store attachment in database in initial form. + err := m.state.DB.PutAttachment(ctx, attachment) if err != nil { return nil, err } - processingMedia := &ProcessingMedia{ - media: attachment, - dataFn: data, - recache: true, // Indicate it's a recache. - mgr: m, - } - - return processingMedia, nil + // Pass prepared media as ready to be cached. + return m.RecacheMedia(attachment, data), nil } -// PreProcessEmoji begins the process of decoding and storing -// the given data as an emoji. It will return a pointer to a -// ProcessingEmoji struct upon which further actions can be -// performed, such as getting the finished media, thumbnail, -// attachment, etc. -// -// - data: function that the media manager can call -// to return a reader containing the emoji data. -// - shortcode: the emoji shortcode without the ':'s around it. -// - emojiID: database ID that should be used to store the emoji. -// - uri: ActivityPub URI/ID of the emoji. -// - ai: optional and can be nil. Any additional information -// about the emoji provided will be put in the database. -// - refresh: refetch/refresh the emoji. -// -// Note: unlike ProcessEmoji, this will NOT queue -// the emoji to be asynchronously processed. -func (m *Manager) PreProcessEmoji( - ctx context.Context, +// RecacheMedia wraps a media model (assumed already +// inserted in the database!) with given data function +// to perform a blocking dereference / decode operation +// from the data stream returned. +func (m *Manager) RecacheMedia( + media *gtsmodel.MediaAttachment, data DataFunc, +) *ProcessingMedia { + return &ProcessingMedia{ + media: media, + dataFn: data, + mgr: m, + } +} + +// CreateEmoji creates a new emoji entry in the +// database for given shortcode, domain and extra +// information, and prepares a new processing emoji +// entry to dereference it using the given data +// function, decode the media and finish filling +// out remaining fields (e.g. type, path, etc). +func (m *Manager) CreateEmoji( + ctx context.Context, shortcode string, - emojiID string, - uri string, - ai *AdditionalEmojiInfo, - refresh bool, -) (*ProcessingEmoji, error) { - var ( - newPathID string - emoji *gtsmodel.Emoji - now = time.Now() - ) + domain string, + data DataFunc, + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { + now := time.Now() + + // Generate new ID. + id := id.NewULID() // Fetch the local instance account for emoji path generation. instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") @@ -233,206 +229,240 @@ func (m *Manager) PreProcessEmoji( return nil, gtserror.Newf("error fetching instance account: %w", err) } - if refresh { - // Existing emoji! + if domain == "" && info.URI == nil { + // Generate URI for local emoji. + uri := uris.URIForEmoji(id) + info.URI = &uri + } - emoji, err = m.state.DB.GetEmojiByID(ctx, emojiID) + // Generate static URL for attachment. + staticURL := uris.URIForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + id, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Generate static image path for attachment. + staticPath := uris.StoragePathForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + id, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Populate initial fields on the new emoji, + // leaving out fields with values we don't know + // yet. These will be overwritten as we go. + emoji := >smodel.Emoji{ + ID: id, + Shortcode: shortcode, + Domain: domain, + ImageStaticURL: staticURL, + ImageStaticPath: staticPath, + ImageStaticContentType: mimeImagePng, + Disabled: util.Ptr(false), + VisibleInPicker: util.Ptr(true), + CreatedAt: now, + UpdatedAt: now, + } + + // Finally, create new emoji. + return m.createEmoji(ctx, + m.state.DB.PutEmoji, + data, + emoji, + info, + ) +} + +// RefreshEmoji will prepare a recache operation +// for the given emoji, updating it with extra +// information, and in particular using new storage +// paths for the dereferenced media files to skirt +// around browser caching of the old files. +func (m *Manager) RefreshEmoji( + ctx context.Context, + emoji *gtsmodel.Emoji, + data DataFunc, + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { + // Fetch the local instance account for emoji path generation. + instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") + if err != nil { + return nil, gtserror.Newf("error fetching instance account: %w", err) + } + + // Create references to old emoji image + // paths before they get updated with new + // path ID. These are required for later + // deleting the old image files on refresh. + shortcodeDomain := util.ShortcodeDomain(emoji) + oldStaticPath := emoji.ImageStaticPath + oldPath := emoji.ImagePath + + // Since this is a refresh we will end up storing new images at new + // paths, so we should wrap closer to delete old paths at completion. + wrapped := func(ctx context.Context) (io.ReadCloser, int64, error) { + + // Call original data func. + rc, sz, err := data(ctx) if err != nil { - err = gtserror.Newf("error fetching emoji to refresh from the db: %w", err) - return nil, err + return nil, 0, err } - // Since this is a refresh, we will end up with - // new images stored for this emoji, so we should - // use an io.Closer callback to perform clean up - // of the original images from storage. - originalData := data - originalImagePath := emoji.ImagePath - originalImageStaticPath := emoji.ImageStaticPath + // Wrap closer to cleanup old data. + c := iotools.CloserFunc(func() error { - data = func(ctx context.Context) (io.ReadCloser, int64, error) { - // Call original data func. - rc, sz, err := originalData(ctx) - if err != nil { - return nil, 0, err + // First try close original. + if rc.Close(); err != nil { + return err } - // Wrap closer to cleanup old data. - c := iotools.CloserCallback(rc, func() { - if err := m.state.Storage.Delete(ctx, originalImagePath); err != nil && !storage.IsNotFound(err) { - log.Errorf(ctx, "error removing old emoji %s@%s from storage: %v", emoji.Shortcode, emoji.Domain, err) - } + // Remove any *old* emoji image file path now stream is closed. + if err := m.state.Storage.Delete(ctx, oldPath); err != nil && + !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting old emoji %s from storage: %v", shortcodeDomain, err) + } - if err := m.state.Storage.Delete(ctx, originalImageStaticPath); err != nil && !storage.IsNotFound(err) { - log.Errorf(ctx, "error removing old static emoji %s@%s from storage: %v", emoji.Shortcode, emoji.Domain, err) - } - }) + // Remove any *old* emoji static image file path now stream is closed. + if err := m.state.Storage.Delete(ctx, oldStaticPath); err != nil && + !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting old static emoji %s from storage: %v", shortcodeDomain, err) + } - // Return newly wrapped readcloser and size. - return iotools.ReadCloser(rc, c), sz, nil - } + return nil + }) - // Reuse existing shortcode and URI - - // these don't change when we refresh. - emoji.Shortcode = shortcode - emoji.URI = uri - - // Use a new ID to create a new path - // for the new images, to get around - // needing to do cache invalidation. - newPathID, err = id.NewRandomULID() - if err != nil { - return nil, gtserror.Newf("error generating alternateID for emoji refresh: %s", err) - } - - emoji.ImageStaticURL = uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - // All static emojis - // are encoded as png. - mimePng, - ) - - emoji.ImageStaticPath = uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - // All static emojis - // are encoded as png. - mimePng, - ) - } else { - // New emoji! - - imageStaticURL := uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - emojiID, - // All static emojis - // are encoded as png. - mimePng, - ) - - imageStaticPath := uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - emojiID, - // All static emojis - // are encoded as png. - mimePng, - ) - - // Populate initial fields on the new emoji, - // leaving out fields with values we don't know - // yet. These will be overwritten as we go. - emoji = >smodel.Emoji{ - ID: emojiID, - CreatedAt: now, - UpdatedAt: now, - Shortcode: shortcode, - ImageStaticURL: imageStaticURL, - ImageStaticPath: imageStaticPath, - ImageStaticContentType: mimeImagePng, - ImageUpdatedAt: now, - Disabled: util.Ptr(false), - URI: uri, - VisibleInPicker: util.Ptr(true), - } + // Return newly wrapped readcloser and size. + return iotools.ReadCloser(rc, c), sz, nil } + // Use a new ID to create a new path + // for the new images, to get around + // needing to do cache invalidation. + newPathID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.Newf("error generating newPathID for emoji refresh: %s", err) + } + + // Generate new static URL for emoji. + emoji.ImageStaticURL = uris.URIForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + newPathID, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Generate new static image storage path for emoji. + emoji.ImageStaticPath = uris.StoragePathForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + newPathID, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Finally, create new emoji in database. + processingEmoji, err := m.createEmoji(ctx, + func(ctx context.Context, emoji *gtsmodel.Emoji) error { + return m.state.DB.UpdateEmoji(ctx, emoji) + }, + wrapped, + emoji, + info, + ) + if err != nil { + return nil, err + } + + // Set the refreshed path ID used. + processingEmoji.newPathID = newPathID + + return processingEmoji, nil +} + +func (m *Manager) createEmoji( + ctx context.Context, + putDB func(context.Context, *gtsmodel.Emoji) error, + data DataFunc, + emoji *gtsmodel.Emoji, + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { // Check if we have additional info to add to the emoji, // and overwrite some of the emoji fields if so. - if ai != nil { - if ai.CreatedAt != nil { - emoji.CreatedAt = *ai.CreatedAt - } - - if ai.Domain != nil { - emoji.Domain = *ai.Domain - } - - if ai.ImageRemoteURL != nil { - emoji.ImageRemoteURL = *ai.ImageRemoteURL - } - - if ai.ImageStaticRemoteURL != nil { - emoji.ImageStaticRemoteURL = *ai.ImageStaticRemoteURL - } - - if ai.Disabled != nil { - emoji.Disabled = ai.Disabled - } - - if ai.VisibleInPicker != nil { - emoji.VisibleInPicker = ai.VisibleInPicker - } - - if ai.CategoryID != nil { - emoji.CategoryID = *ai.CategoryID - } + if info.URI != nil { + emoji.URI = *info.URI + } + if info.CreatedAt != nil { + emoji.CreatedAt = *info.CreatedAt + } + if info.Domain != nil { + emoji.Domain = *info.Domain + } + if info.ImageRemoteURL != nil { + emoji.ImageRemoteURL = *info.ImageRemoteURL + } + if info.ImageStaticRemoteURL != nil { + emoji.ImageStaticRemoteURL = *info.ImageStaticRemoteURL + } + if info.Disabled != nil { + emoji.Disabled = info.Disabled + } + if info.VisibleInPicker != nil { + emoji.VisibleInPicker = info.VisibleInPicker + } + if info.CategoryID != nil { + emoji.CategoryID = *info.CategoryID } + // Store emoji in database in initial form. + if err := putDB(ctx, emoji); err != nil { + return nil, err + } + + // Return wrapped emoji for later processing. processingEmoji := &ProcessingEmoji{ - emoji: emoji, - existing: refresh, - newPathID: newPathID, - dataFn: data, - mgr: m, + emoji: emoji, + dataFn: data, + mgr: m, } return processingEmoji, nil } -// PreProcessEmojiRecache refetches, reprocesses, and recaches -// an existing emoji that has been uncached via cleaner pruning. -// -// Note: unlike ProcessEmoji, this will NOT queue the emoji to -// be asychronously processed. -func (m *Manager) PreProcessEmojiRecache( - ctx context.Context, +// RecacheEmoji wraps an emoji model (assumed already +// inserted in the database!) with given data function +// to perform a blocking dereference / decode operation +// from the data stream returned. +func (m *Manager) RecacheEmoji( + emoji *gtsmodel.Emoji, data DataFunc, - emojiID string, -) (*ProcessingEmoji, error) { - // Get the existing emoji from the database. - emoji, err := m.state.DB.GetEmojiByID(ctx, emojiID) - if err != nil { - return nil, err +) *ProcessingEmoji { + return &ProcessingEmoji{ + emoji: emoji, + dataFn: data, + mgr: m, } - - processingEmoji := &ProcessingEmoji{ - emoji: emoji, - dataFn: data, - existing: true, // Indicate recache. - mgr: m, - } - - return processingEmoji, nil -} - -// ProcessEmoji will call PreProcessEmoji, followed -// by queuing the emoji in the emoji worker queue. -func (m *Manager) ProcessEmoji( - ctx context.Context, - data DataFunc, - shortcode string, - id string, - uri string, - ai *AdditionalEmojiInfo, - refresh bool, -) (*ProcessingEmoji, error) { - // Create a new processing emoji object for this emoji request. - emoji, err := m.PreProcessEmoji(ctx, data, shortcode, id, uri, ai, refresh) - if err != nil { - return nil, err - } - - // Attempt to add emoji item to the worker queue. - m.state.Workers.Media.Queue.Push(emoji.Process) - - return emoji, nil } diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index d184e4605..53c08eed8 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -40,7 +40,7 @@ type ManagerTestSuite struct { MediaStandardTestSuite } -func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { +func (suite *ManagerTestSuite) TestEmojiProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -52,27 +52,26 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "rainbow_test", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "rainbow_test", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(36702, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -101,14 +100,15 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { suite.Equal(processedStaticBytesExpected, processedStaticBytes) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { +func (suite *ManagerTestSuite) TestEmojiProcessRefresh() { ctx := context.Background() // we're going to 'refresh' the remote 'yell' emoji by changing the image url to the pixellated gts logo originalEmoji := suite.testEmojis["yell"] - emojiToUpdate := >smodel.Emoji{} - *emojiToUpdate = *originalEmoji + emojiToUpdate, err := suite.db.GetEmojiByID(ctx, originalEmoji.ID) + suite.NoError(err) + newImageRemoteURL := "http://fossbros-anonymous.io/some/image/path.png" oldEmojiImagePath := emojiToUpdate.ImagePath @@ -122,23 +122,24 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := emojiToUpdate.ID - emojiURI := emojiToUpdate.URI - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "yell", emojiID, emojiURI, &media.AdditionalEmojiInfo{ - CreatedAt: &emojiToUpdate.CreatedAt, - Domain: &emojiToUpdate.Domain, - ImageRemoteURL: &newImageRemoteURL, - }, true) + processing, err := suite.manager.RefreshEmoji(ctx, + emojiToUpdate, + data, + media.AdditionalEmojiInfo{ + CreatedAt: &emojiToUpdate.CreatedAt, + Domain: &emojiToUpdate.Domain, + ImageRemoteURL: &newImageRemoteURL, + }, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) + suite.Equal(originalEmoji.ID, emoji.ID) // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) @@ -146,7 +147,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.Equal(10296, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -185,7 +186,6 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath) suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath) suite.NotEqual(originalEmoji.UpdatedAt, dbEmoji.UpdatedAt) - suite.NotEqual(originalEmoji.ImageUpdatedAt, dbEmoji.ImageUpdatedAt) // the old image files should no longer be in storage _, err = suite.storage.Get(ctx, oldEmojiImagePath) @@ -194,7 +194,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.True(storage.IsNotFound(err)) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() { +func (suite *ManagerTestSuite) TestEmojiProcessTooLarge() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -206,19 +206,20 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "big_panda", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "big_panda", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + _, err = processing.Load(ctx) suite.EqualError(err, "store: given emoji size 630kiB greater than max allowed 50.0kiB") - suite.Nil(emoji) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { +func (suite *ManagerTestSuite) TestEmojiProcessTooLargeNoSizeGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -230,19 +231,20 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { return io.NopCloser(bytes.NewBuffer(b)), -1, nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "big_panda", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "big_panda", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) - suite.EqualError(err, "store: calculated emoji size 630kiB greater than max allowed 50.0kiB") - suite.Nil(emoji) + _, err = processing.Load(ctx) + suite.EqualError(err, "store: written emoji size 630kiB greater than max allowed 50.0kiB") } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingNoFileSizeGiven() { +func (suite *ManagerTestSuite) TestEmojiProcessNoFileSizeGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -254,28 +256,27 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingNoFileSizeGiven() { return io.NopCloser(bytes.NewBuffer(b)), -1, nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - // process the media with no additional info provided - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "rainbow_test", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "rainbow_test", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(36702, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -316,27 +317,27 @@ func (suite *ManagerTestSuite) TestEmojiWebpProcess() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "nb-flag", emojiID, emojiURI, nil, false) + // process the media with no additional info provided + processing, err := suite.manager.CreateEmoji(ctx, + "nb-flag", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/webp", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(294, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -365,7 +366,7 @@ func (suite *ManagerTestSuite) TestEmojiWebpProcess() { suite.Equal(processedStaticBytesExpected, processedStaticBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { +func (suite *ManagerTestSuite) TestSimpleJpegProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -380,18 +381,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -407,7 +412,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -456,13 +461,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) // Since we're cutting off the byte stream // halfway through, we should get an error here. @@ -471,17 +479,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() { // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image suite.Zero(attachment.FileMeta) suite.Equal("image/jpeg", attachment.File.ContentType) - suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) suite.Empty(attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -518,19 +525,22 @@ func (suite *ManagerTestSuite) TestPDFProcess() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -540,7 +550,7 @@ func (suite *ManagerTestSuite) TestPDFProcess() { suite.Empty(attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -561,7 +571,7 @@ func (suite *ManagerTestSuite) TestPDFProcess() { suite.False(stored) } -func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { +func (suite *ManagerTestSuite) TestSlothVineProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -576,18 +586,22 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -607,7 +621,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -636,7 +650,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestLongerMp4Process() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -651,18 +665,22 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -682,7 +700,7 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -711,7 +729,7 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestBirdnestMp4Process() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -726,18 +744,22 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -757,7 +779,7 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -786,7 +808,7 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestNotAnMp4Process() { // try to load an 'mp4' that's actually an mkv in disguise ctx := context.Background() @@ -803,10 +825,16 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // pre processing should go fine but... - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // we should get an error while loading - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.EqualError(err, "finish: error decoding video: error determining video metadata: [width height framerate]") // partial attachment should be @@ -815,7 +843,7 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { suite.Equal(gtsmodel.FileTypeUnknown, attachment.Type) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessNoContentLengthGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -831,18 +859,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -858,7 +890,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -887,7 +919,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessReadCloser() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -903,18 +935,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -930,7 +966,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -959,7 +995,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { +func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -974,18 +1010,22 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1001,7 +1041,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1030,7 +1070,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { +func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1045,18 +1085,22 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1072,7 +1116,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1101,7 +1145,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1116,18 +1160,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1143,7 +1191,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1172,7 +1220,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1209,18 +1257,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { suite.manager = diskManager // process the media with no additional info provided - processingMedia := diskManager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1236,7 +1288,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1307,22 +1359,27 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - if _, err := processingMedia.LoadAttachment(ctx); err != nil { - suite.FailNow(err.Error()) - } + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) - attachmentID := processingMedia.AttachmentID() + // Load the attachment (but ignore return). + _, err = processing.Load(ctx) + suite.NoError(err) // fetch the attachment id from the processing media - attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + attachment, err := suite.db.GetAttachmentByID(ctx, processing.ID()) if err != nil { suite.FailNow(err.Error()) } // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) actual := attachment.File.ContentType @@ -1350,13 +1407,21 @@ func (suite *ManagerTestSuite) TestMisreportedSmallMedia() { return io.NopCloser(bytes.NewBuffer(b)), int64(2 * actualSize), nil } - // Process the media with no additional info provided. - attachment, err := suite.manager. - PreProcessMedia(data, accountID, nil). - LoadAttachment(context.Background()) - if err != nil { - suite.FailNow(err.Error()) - } + ctx := context.Background() + + // process the media with no additional info provided + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) + + // do a blocking call to fetch the attachment + attachment, err := processing.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) suite.Equal(actualSize, attachment.File.FileSize) } @@ -1378,13 +1443,21 @@ func (suite *ManagerTestSuite) TestNoReportedSizeSmallMedia() { return io.NopCloser(bytes.NewBuffer(b)), 0, nil } - // Process the media with no additional info provided. - attachment, err := suite.manager. - PreProcessMedia(data, accountID, nil). - LoadAttachment(context.Background()) - if err != nil { - suite.FailNow(err.Error()) - } + ctx := context.Background() + + // process the media with no additional info provided + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) + + // do a blocking call to fetch the attachment + attachment, err := processing.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) suite.Equal(actualSize, attachment.File.FileSize) } diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index b62c4f76e..d61043523 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -24,14 +24,16 @@ import ( "slices" "codeberg.org/gruf/go-bytesize" - "codeberg.org/gruf/go-errors/v2" + errorsv2 "codeberg.org/gruf/go-errors/v2" "codeberg.org/gruf/go-runners" "github.com/h2non/filetype" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/regexes" + "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -40,7 +42,6 @@ import ( // various functions for retrieving data from the process. type ProcessingEmoji struct { emoji *gtsmodel.Emoji // processing emoji details - existing bool // indicates whether this is an existing emoji ID being refreshed / recached newPathID string // new emoji path ID to use when being refreshed dataFn DataFunc // load-data function, returns media stream done bool // done is set when process finishes with non ctx canceled type error @@ -49,61 +50,72 @@ type ProcessingEmoji struct { mgr *Manager // mgr instance (access to db / storage) } -// EmojiID returns the ID of the underlying emoji without blocking processing. -func (p *ProcessingEmoji) EmojiID() string { +// ID returns the ID of the underlying emoji. +func (p *ProcessingEmoji) ID() string { return p.emoji.ID // immutable, safe outside mutex. } // LoadEmoji blocks until the static and fullsize image has been processed, and then returns the completed emoji. -func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - // Attempt to load synchronously. +func (p *ProcessingEmoji) Load(ctx context.Context) (*gtsmodel.Emoji, error) { emoji, done, err := p.load(ctx) - if err == nil { - // No issue, return media. - return emoji, nil - } - if !done { - // Provided context was cancelled, e.g. request cancelled - // early. Queue this item for asynchronous processing. - log.Warnf(ctx, "reprocessing emoji %s after canceled ctx", p.emoji.ID) - p.mgr.state.Workers.Media.Queue.Push(p.Process) + // On a context-canceled error (marked as !done), requeue for loading. + p.mgr.state.Workers.Dereference.Queue.Push(func(ctx context.Context) { + if _, _, err := p.load(ctx); err != nil { + log.Errorf(ctx, "error loading emoji: %v", err) + } + }) } - - return nil, err + return emoji, err } -// Process allows the receiving object to fit the runners.WorkerFunc signature. It performs a (blocking) load and logs on error. -func (p *ProcessingEmoji) Process(ctx context.Context) { - if _, _, err := p.load(ctx); err != nil { - log.Errorf(ctx, "error processing emoji: %v", err) - } -} - -// load performs a concurrency-safe load of ProcessingEmoji, only marking itself as complete when returned error is NOT a context cancel. -func (p *ProcessingEmoji) load(ctx context.Context) (*gtsmodel.Emoji, bool, error) { - var ( - done bool - err error - ) - +// load is the package private form of load() that is wrapped to catch context canceled. +func (p *ProcessingEmoji) load(ctx context.Context) ( + emoji *gtsmodel.Emoji, + done bool, + err error, +) { err = p.proc.Process(func() error { - if p.done { + if done = p.done; done { // Already proc'd. return p.err } defer func() { // This is only done when ctx NOT cancelled. - done = err == nil || !errors.IsV2(err, + done = (err == nil || !errorsv2.IsV2(err, context.Canceled, context.DeadlineExceeded, - ) + )) if !done { return } + // Anything from here, we + // need to ensure happens + // (i.e. no ctx canceled). + ctx = gtscontext.WithValues( + context.Background(), + ctx, // values + ) + + // On error, clean + // downloaded files. + if err != nil { + p.cleanup(ctx) + } + + if !done { + return + } + + // 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 final values. p.done = true p.err = err @@ -111,39 +123,31 @@ func (p *ProcessingEmoji) load(ctx context.Context) (*gtsmodel.Emoji, bool, erro // Attempt to store media and calculate // full-size media attachment details. + // + // This will update p.emoji as it goes. if err = p.store(ctx); err != nil { return err } // Finish processing by reloading media into // memory to get dimension and generate a thumb. + // + // This will update p.emoji as it goes. if err = p.finish(ctx); err != nil { - return err + return err //nolint:revive } - if p.existing { - // Existing emoji we're updating, so only update. - err = p.mgr.state.DB.UpdateEmoji(ctx, p.emoji) - return err - } - - // New emoji media, first time caching. - err = p.mgr.state.DB.PutEmoji(ctx, p.emoji) - return err + return nil }) - - if err != nil { - return nil, done, err - } - - return p.emoji, done, nil + emoji = p.emoji + return } // store calls the data function attached to p if it hasn't been called yet, // and updates the underlying attachment fields as necessary. It will then stream // bytes from p's reader directly into storage so that it can be retrieved later. func (p *ProcessingEmoji) store(ctx context.Context) error { - // Load media from provided data fn. + // Load media from provided data fun rc, sz, err := p.dataFn(ctx) if err != nil { return gtserror.Newf("error executing data function: %w", err) @@ -168,8 +172,9 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { // Check that provided size isn't beyond max. We check beforehand // so that we don't attempt to stream the emoji into storage if not needed. - if size := bytesize.Size(sz); sz > 0 && size > maxSize { - return gtserror.Newf("given emoji size %s greater than max allowed %s", size, maxSize) + if sz > 0 && sz > int64(maxSize) { + sz := bytesize.Size(sz) // improves log readability + return gtserror.Newf("given emoji size %s greater than max allowed %s", sz, maxSize) } // Prepare to read bytes from @@ -196,14 +201,14 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { // Initial file size was misreported, so we didn't read // fully into hdrBuf. Reslice it to the size we did read. - log.Warnf(ctx, - "recovered from misreported file size; reported %d; read %d", - fileSize, n, - ) hdrBuf = hdrBuf[:n] + fileSize = n + p.emoji.ImageFileSize = fileSize } // Parse file type info from header buffer. + // This should only ever error if the buffer + // is empty (ie., the attachment is 0 bytes). info, err := filetype.Match(hdrBuf) if err != nil { return gtserror.Newf("error parsing file type: %w", err) @@ -227,10 +232,13 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { pathID = p.emoji.ID } - // Determine instance account ID from already generated image static path. - instanceAccID := regexes.FilePath.FindStringSubmatch(p.emoji.ImageStaticPath)[1] + // Determine instance account ID from generated image static path. + instanceAccID, ok := getInstanceAccountID(p.emoji.ImageStaticPath) + if !ok { + return gtserror.Newf("invalid emoji static path; no instance account id: %s", p.emoji.ImageStaticPath) + } - // Calculate emoji file path. + // Calculate final media attachment file path. p.emoji.ImagePath = uris.StoragePathForAttachment( instanceAccID, string(TypeEmoji), @@ -239,32 +247,32 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { info.Extension, ) - // This shouldn't already exist, but we do a check as it's worth logging. + // File shouldn't already exist in storage at this point, + // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImagePath); have { - log.Warnf(ctx, "emoji already exists at storage path: %s", p.emoji.ImagePath) + log.Warnf(ctx, "emoji already exists at: %s", p.emoji.ImagePath) // Attempt to remove existing emoji at storage path (might be broken / out-of-date) if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil { - return gtserror.Newf("error removing emoji from storage: %v", err) + return gtserror.Newf("error removing emoji %s from storage: %v", p.emoji.ImagePath, err) } } // Write the final image reader stream to our storage. - wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r) + sz, err = p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r) if err != nil { return gtserror.Newf("error writing emoji to storage: %w", err) } - // Once again check size in case none was provided previously. - if size := bytesize.Size(wroteSize); size > maxSize { - if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil { - log.Errorf(ctx, "error removing too-large-emoji from storage: %v", err) - } - - return gtserror.Newf("calculated emoji size %s greater than max allowed %s", size, maxSize) + // Perform final size check in case none was + // given previously, or size was mis-reported. + // (error here will later perform p.cleanup()). + if sz > int64(maxSize) { + sz := bytesize.Size(sz) // improves log readability + return gtserror.Newf("written emoji size %s greater than max allowed %s", sz, maxSize) } - // Fill in remaining attachment data now it's stored. + // Fill in remaining emoji data now it's stored. p.emoji.ImageURL = uris.URIForAttachment( instanceAccID, string(TypeEmoji), @@ -273,14 +281,14 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { info.Extension, ) p.emoji.ImageContentType = info.MIME.Value - p.emoji.ImageFileSize = int(wroteSize) + p.emoji.ImageFileSize = int(sz) p.emoji.Cached = util.Ptr(true) return nil } func (p *ProcessingEmoji) finish(ctx context.Context) error { - // Fetch a stream to the original file in storage. + // Get a stream to the original file for further processing. rc, err := p.mgr.state.Storage.GetStream(ctx, p.emoji.ImagePath) if err != nil { return gtserror.Newf("error loading file from storage: %w", err) @@ -293,32 +301,69 @@ func (p *ProcessingEmoji) finish(ctx context.Context) error { return gtserror.Newf("error decoding image: %w", err) } - // The image should be in-memory by now. + // staticImg should be in-memory by + // now so we're done with storage. if err := rc.Close(); err != nil { return gtserror.Newf("error closing file: %w", err) } - // This shouldn't already exist, but we do a check as it's worth logging. + // Static img shouldn't exist in storage at this point, + // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImageStaticPath); have { - log.Warnf(ctx, "static emoji already exists at storage path: %s", p.emoji.ImagePath) + log.Warnf(ctx, "static emoji already exists at: %s", p.emoji.ImageStaticPath) - // Attempt to remove static existing emoji at storage path (might be broken / out-of-date) + // Attempt to remove existing thumbnail (might be broken / out-of-date). if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath); err != nil { - return gtserror.Newf("error removing static emoji from storage: %v", err) + return gtserror.Newf("error removing static emoji %s from storage: %v", p.emoji.ImageStaticPath, err) } } - // Create an emoji PNG encoder stream. + // Create emoji PNG encoder stream. enc := staticImg.ToPNG() - // Stream-encode the PNG static image into storage. + // Stream-encode the PNG static emoji image into our storage driver. sz, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImageStaticPath, enc) if err != nil { return gtserror.Newf("error stream-encoding static emoji to storage: %w", err) } - // Set written image size. + // Set final written thumb size. p.emoji.ImageStaticFileSize = int(sz) return nil } + +// cleanup will remove any traces of processing emoji from storage, +// and perform any other necessary cleanup steps after failure. +func (p *ProcessingEmoji) cleanup(ctx context.Context) { + var err error + + if p.emoji.ImagePath != "" { + // Ensure emoji file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.emoji.ImagePath, err) + } + } + + if p.emoji.ImageStaticPath != "" { + // Ensure emoji static file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.emoji.ImageStaticPath, err) + } + } + + // Ensure marked as not cached. + p.emoji.Cached = util.Ptr(false) +} + +// getInstanceAccountID determines the instance account ID from +// emoji static image storage path. returns false on failure. +func getInstanceAccountID(staticPath string) (string, bool) { + matches := regexes.FilePath.FindStringSubmatch(staticPath) + if len(matches) < 2 { + return "", false + } + return matches[1], true +} diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index b65e3cd48..466c3443f 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -19,6 +19,7 @@ package media import ( "bytes" + "cmp" "context" "image/jpeg" "io" @@ -29,6 +30,7 @@ import ( terminator "codeberg.org/superseriousbusiness/exif-terminator" "github.com/disintegration/imaging" "github.com/h2non/filetype" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -41,18 +43,16 @@ import ( // currently being processed. It exposes functions // for retrieving data from the process. type ProcessingMedia struct { - media *gtsmodel.MediaAttachment // processing media attachment details - dataFn DataFunc // load-data function, returns media stream - recache bool // recaching existing (uncached) media - done bool // done is set when process finishes with non ctx canceled type error - proc runners.Processor // proc helps synchronize only a singular running processing instance - err error // error stores permanent error value when done - mgr *Manager // mgr instance (access to db / storage) + media *gtsmodel.MediaAttachment // processing media attachment details + dataFn DataFunc // load-data function, returns media stream + done bool // done is set when process finishes with non ctx canceled type error + proc runners.Processor // proc helps synchronize only a singular running processing instance + err error // error stores permanent error value when done + mgr *Manager // mgr instance (access to db / storage) } -// AttachmentID returns the ID of the underlying -// media attachment without blocking processing. -func (p *ProcessingMedia) AttachmentID() string { +// ID returns the ID of the underlying media. +func (p *ProcessingMedia) ID() string { return p.media.ID // immutable, safe outside mutex. } @@ -65,124 +65,102 @@ func (p *ProcessingMedia) AttachmentID() string { // will still be returned in that case, but it will // only be partially complete and should be treated // as a placeholder. -func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - // Attempt to load synchronously. +func (p *ProcessingMedia) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { media, done, err := p.load(ctx) - if err == nil { - // No issue, return media. - return media, nil - } - if !done { - // Provided context was cancelled, - // e.g. request aborted early before - // its context could be used to finish - // loading the attachment. Enqueue for - // asynchronous processing, which will - // use a background context. - log.Warnf(ctx, "reprocessing media %s after canceled ctx", p.media.ID) - p.mgr.state.Workers.Media.Queue.Push(p.Process) + // On a context-canceled error (marked as !done), requeue for loading. + p.mgr.state.Workers.Dereference.Queue.Push(func(ctx context.Context) { + if _, _, err := p.load(ctx); err != nil { + log.Errorf(ctx, "error loading media: %v", err) + } + }) } - - // Media could not be retrieved FULLY, - // but partial attachment should be present. return media, err } -// Process allows the receiving object to fit the -// runners.WorkerFunc signature. It performs a -// (blocking) load and logs on error. -func (p *ProcessingMedia) Process(ctx context.Context) { - if _, _, err := p.load(ctx); err != nil { - log.Errorf(ctx, "error(s) processing media: %v", err) - } -} - -// load performs a concurrency-safe load of ProcessingMedia, only -// marking itself as complete when returned error is NOT a context cancel. -func (p *ProcessingMedia) load(ctx context.Context) (*gtsmodel.MediaAttachment, bool, error) { - var ( - done bool - err error - ) - +// load is the package private form of load() that is wrapped to catch context canceled. +func (p *ProcessingMedia) load(ctx context.Context) ( + media *gtsmodel.MediaAttachment, + done bool, + err error, +) { err = p.proc.Process(func() error { - if p.done { + if done = p.done; done { // Already proc'd. return p.err } defer func() { // This is only done when ctx NOT cancelled. - done = err == nil || !errorsv2.IsV2(err, + done = (err == nil || !errorsv2.IsV2(err, context.Canceled, context.DeadlineExceeded, - ) + )) if !done { return } + // Anything from here, we + // need to ensure happens + // (i.e. no ctx canceled). + ctx = gtscontext.WithValues( + context.Background(), + ctx, // values + ) + + // On error or unknown media types, perform error cleanup. + if err != nil || p.media.Type == gtsmodel.FileTypeUnknown { + p.cleanup(ctx) + } + + // 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 final values. p.done = true p.err = err }() - // Gather errors as we proceed. - var errs = gtserror.NewMultiError(4) + // TODO: in time update this + // to perhaps follow a similar + // freshness window to statuses + // / accounts? But that's a big + // maybe, media don't change in + // the same way so this is largely + // just to slow down fail retries. + const maxfreq = 6 * time.Hour + + // Check whether media is uncached but repeatedly failing, + // specifically limit the frequency at which we allow this. + if !p.media.UpdatedAt.Equal(p.media.CreatedAt) && // i.e. not new + p.media.UpdatedAt.Add(maxfreq).Before(time.Now()) { + return nil + } // Attempt to store media and calculate // full-size media attachment details. // // This will update p.media as it goes. - storeErr := p.store(ctx) - if storeErr != nil { - errs.Append(storeErr) + if err = p.store(ctx); err != nil { + return err } // Finish processing by reloading media into // memory to get dimension and generate a thumb. // // This will update p.media as it goes. - if finishErr := p.finish(ctx); finishErr != nil { - errs.Append(finishErr) + if err = p.finish(ctx); err != nil { + return err //nolint:revive } - // If this isn't a file we were able to process, - // we may have partially stored it (eg., it's a - // jpeg, which is fine, but streaming it to storage - // was interrupted halfway through and so it was - // never decoded). Try to clean up in this case. - if p.media.Type == gtsmodel.FileTypeUnknown { - deleteErr := p.mgr.state.Storage.Delete(ctx, p.media.File.Path) - if deleteErr != nil && !storage.IsNotFound(deleteErr) { - errs.Append(deleteErr) - } - } - - var dbErr error - switch { - case !p.recache: - // First time caching this attachment, insert it. - dbErr = p.mgr.state.DB.PutAttachment(ctx, p.media) - - case p.recache && len(errs) == 0: - // Existing attachment we're recaching, update it. - // - // (We only want to update if everything went OK so far, - // otherwise we'd better leave previous version alone.) - dbErr = p.mgr.state.DB.UpdateAttachment(ctx, p.media) - } - - if dbErr != nil { - errs.Append(dbErr) - } - - err = errs.Combine() - return err + return nil }) - - return p.media, done, err + media = p.media + return } // store calls the data function attached to p if it hasn't been called yet, @@ -231,10 +209,6 @@ func (p *ProcessingMedia) store(ctx context.Context) error { // Initial file size was misreported, so we didn't read // fully into hdrBuf. Reslice it to the size we did read. - log.Warnf(ctx, - "recovered from misreported file size; reported %d; read %d", - fileSize, n, - ) hdrBuf = hdrBuf[:n] fileSize = n p.media.File.FileSize = fileSize @@ -273,20 +247,13 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } default: - // The file is not a supported format that - // we can process, so we can't do much with it. - log.Warnf(ctx, - "media extension '%s' not officially supported, will be processed as "+ - "type '%s' with minimal metadata, and will not be cached locally", - info.Extension, gtsmodel.FileTypeUnknown, - ) - - // Don't bother storing this. + // The file is not a supported format that we can process, so we can't do much with it. + log.Warnf(ctx, "unsupported media extension '%s'; not caching locally", info.Extension) store = false } // Fill in correct attachment - // data now we're parsed it. + // data now we've parsed it. p.media.URL = uris.URIForAttachment( p.media.AccountID, string(TypeAttachment), @@ -295,15 +262,11 @@ func (p *ProcessingMedia) store(ctx context.Context) error { info.Extension, ) - // Prefer discovered mime type, fall back to - // generic "this contains some bytes" type. - mime := info.MIME.Value - if mime == "" { - mime = "application/octet-stream" - } + // Prefer discovered MIME, fallback to generic data stream. + mime := cmp.Or(info.MIME.Value, "application/octet-stream") p.media.File.ContentType = mime - // Calculate attachment file path. + // Calculate final media attachment file path. p.media.File.Path = uris.StoragePathForAttachment( p.media.AccountID, string(TypeAttachment), @@ -323,23 +286,23 @@ func (p *ProcessingMedia) store(ctx context.Context) error { // File shouldn't already exist in storage at this point, // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.media.File.Path); have { - log.Warnf(ctx, "media already exists at storage path: %s", p.media.File.Path) + log.Warnf(ctx, "media already exists at: %s", p.media.File.Path) // Attempt to remove existing media at storage path (might be broken / out-of-date) if err := p.mgr.state.Storage.Delete(ctx, p.media.File.Path); err != nil { - return gtserror.Newf("error removing media from storage: %v", err) + return gtserror.Newf("error removing media %s from storage: %v", p.media.File.Path, err) } } - // Write the final reader stream to our storage. - wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r) + // Write the final reader stream to our storage driver. + sz, err = p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r) if err != nil { return gtserror.Newf("error writing media to storage: %w", err) } // Set actual written size // as authoritative file size. - p.media.File.FileSize = int(wroteSize) + p.media.File.FileSize = int(sz) // We can now consider this cached. p.media.Cached = util.Ptr(true) @@ -348,36 +311,9 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } func (p *ProcessingMedia) finish(ctx context.Context) error { - // Make a jolly assumption about thumbnail type. - p.media.Thumbnail.ContentType = mimeImageJpeg - - // Calculate attachment thumbnail file path - p.media.Thumbnail.Path = uris.StoragePathForAttachment( - p.media.AccountID, - string(TypeAttachment), - string(SizeSmall), - p.media.ID, - // Always encode attachment - // thumbnails as jpg. - "jpg", - ) - - // Calculate attachment thumbnail serve path. - p.media.Thumbnail.URL = uris.URIForAttachment( - p.media.AccountID, - string(TypeAttachment), - string(SizeSmall), - p.media.ID, - // Always encode attachment - // thumbnails as jpg. - "jpg", - ) - - // If original file hasn't been stored, there's - // likely something wrong with the data, or we - // don't want to store it. Skip everything else. + // Nothing else to do if + // media was not cached. if !*p.media.Cached { - p.media.Processing = gtsmodel.ProcessingStatusProcessed return nil } @@ -398,8 +334,7 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { // .jpeg, .gif, .webp image type case mimeImageJpeg, mimeImageGif, mimeImageWebp: - fullImg, err = decodeImage( - rc, + fullImg, err = decodeImage(rc, imaging.AutoOrientation(true), ) if err != nil { @@ -451,9 +386,9 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { } // Set full-size dimensions in attachment info. - p.media.FileMeta.Original.Width = int(fullImg.Width()) - p.media.FileMeta.Original.Height = int(fullImg.Height()) - p.media.FileMeta.Original.Size = int(fullImg.Size()) + p.media.FileMeta.Original.Width = fullImg.Width() + p.media.FileMeta.Original.Height = fullImg.Height() + p.media.FileMeta.Original.Size = fullImg.Size() p.media.FileMeta.Original.Aspect = fullImg.AspectRatio() // Get smaller thumbnail image @@ -475,44 +410,72 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { p.media.Blurhash = hash } - // Thumbnail shouldn't already exist in storage at this point, + // Thumbnail shouldn't exist in storage at this point, // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.media.Thumbnail.Path); have { - log.Warnf(ctx, "thumbnail already exists at storage path: %s", p.media.Thumbnail.Path) + log.Warnf(ctx, "thumbnail already exists at: %s", p.media.Thumbnail.Path) - // Attempt to remove existing thumbnail at storage path (might be broken / out-of-date) + // Attempt to remove existing thumbnail (might be broken / out-of-date). if err := p.mgr.state.Storage.Delete(ctx, p.media.Thumbnail.Path); err != nil { - return gtserror.Newf("error removing thumbnail from storage: %v", err) + return gtserror.Newf("error removing thumbnail %s from storage: %v", p.media.Thumbnail.Path, err) } } // Create a thumbnail JPEG encoder stream. enc := thumbImg.ToJPEG(&jpeg.Options{ + // Good enough for // a thumbnail. Quality: 70, }) - // Stream-encode the JPEG thumbnail image into storage. + // Stream-encode the JPEG thumbnail image into our storage driver. sz, err := p.mgr.state.Storage.PutStream(ctx, p.media.Thumbnail.Path, enc) if err != nil { return gtserror.Newf("error stream-encoding thumbnail to storage: %w", err) } + // Set final written thumb size. + p.media.Thumbnail.FileSize = int(sz) + // Set thumbnail dimensions in attachment info. p.media.FileMeta.Small = gtsmodel.Small{ - Width: int(thumbImg.Width()), - Height: int(thumbImg.Height()), - Size: int(thumbImg.Size()), + Width: thumbImg.Width(), + Height: thumbImg.Height(), + Size: thumbImg.Size(), Aspect: thumbImg.AspectRatio(), } - // Set written image size. - p.media.Thumbnail.FileSize = int(sz) - - // Finally set the attachment as processed and update time. + // Finally set the attachment as processed. p.media.Processing = gtsmodel.ProcessingStatusProcessed - p.media.File.UpdatedAt = time.Now() return nil } + +// cleanup will remove any traces of processing media from storage. +// and perform any other necessary cleanup steps after failure. +func (p *ProcessingMedia) cleanup(ctx context.Context) { + var err error + + if p.media.File.Path != "" { + // Ensure media file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.media.File.Path) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.media.File.Path, err) + } + } + + if p.media.Thumbnail.Path != "" { + // Ensure media thumbnail at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.media.Thumbnail.Path) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.media.Thumbnail.Path, err) + } + } + + // Also ensure marked as unknown and finished + // processing so gets inserted as placeholder URL. + p.media.Processing = gtsmodel.ProcessingStatusProcessed + p.media.Type = gtsmodel.FileTypeUnknown + p.media.Cached = util.Ptr(false) +} diff --git a/internal/media/refetch.go b/internal/media/refetch.go index a1483ccd4..c239655d2 100644 --- a/internal/media/refetch.go +++ b/internal/media/refetch.go @@ -112,19 +112,19 @@ func (m *Manager) RefetchEmojis(ctx context.Context, domain string, dereferenceM return dereferenceMedia(ctx, emojiImageIRI) } - processingEmoji, err := m.PreProcessEmoji(ctx, dataFunc, emoji.Shortcode, emoji.ID, emoji.URI, &AdditionalEmojiInfo{ + processingEmoji, err := m.RefreshEmoji(ctx, emoji, dataFunc, AdditionalEmojiInfo{ Domain: &emoji.Domain, ImageRemoteURL: &emoji.ImageRemoteURL, ImageStaticRemoteURL: &emoji.ImageStaticRemoteURL, Disabled: emoji.Disabled, VisibleInPicker: emoji.VisibleInPicker, - }, true) + }) if err != nil { log.Errorf(ctx, "emoji %s could not be refreshed because of an error during processing: %s", shortcodeDomain, err) continue } - if _, err := processingEmoji.LoadEmoji(ctx); err != nil { + if _, err := processingEmoji.Load(ctx); err != nil { log.Errorf(ctx, "emoji %s could not be refreshed because of an error during loading: %s", shortcodeDomain, err) continue } diff --git a/internal/media/types.go b/internal/media/types.go index 6e7727cd5..cea026b98 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -61,47 +61,85 @@ const ( TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests ) -// AdditionalMediaInfo represents additional information that should be added to an attachment -// when processing a piece of media. +// AdditionalMediaInfo represents additional information that +// should be added to attachment when processing a piece of media. type AdditionalMediaInfo struct { - // Time that this media was created; defaults to time.Now(). + + // Time that this media was + // created; defaults to time.Now(). CreatedAt *time.Time - // ID of the status to which this media is attached; defaults to "". + + // ID of the status to which this + // media is attached; defaults to "". StatusID *string - // URL of the media on a remote instance; defaults to "". + + // URL of the media on a + // remote instance; defaults to "". RemoteURL *string - // Image description of this media; defaults to "". + + // Image description of + // this media; defaults to "". Description *string - // Blurhash of this media; defaults to "". + + // Blurhash of this + // media; defaults to "". Blurhash *string - // ID of the scheduled status to which this media is attached; defaults to "". + + // ID of the scheduled status to which + // this media is attached; defaults to "". ScheduledStatusID *string - // Mark this media as in-use as an avatar; defaults to false. + + // Mark this media as in-use + // as an avatar; defaults to false. Avatar *bool - // Mark this media as in-use as a header; defaults to false. + + // Mark this media as in-use + // as a header; defaults to false. Header *bool - // X focus coordinate for this media; defaults to 0. + + // X focus coordinate for + // this media; defaults to 0. FocusX *float32 - // Y focus coordinate for this media; defaults to 0. + + // Y focus coordinate for + // this media; defaults to 0. FocusY *float32 } // AdditionalEmojiInfo represents additional information // that should be taken into account when processing an emoji. type AdditionalEmojiInfo struct { - // Time that this emoji was created; defaults to time.Now(). + + // ActivityPub URI of + // this remote emoji. + URI *string + + // Time that this emoji was + // created; defaults to time.Now(). CreatedAt *time.Time - // Domain the emoji originated from. Blank for this instance's domain. Defaults to "". + + // Domain the emoji originated from. Blank + // for this instance's domain. Defaults to "". Domain *string - // URL of this emoji on a remote instance; defaults to "". + + // URL of this emoji on a + // remote instance; defaults to "". ImageRemoteURL *string - // URL of the static version of this emoji on a remote instance; defaults to "". + + // URL of the static version of this emoji + // on a remote instance; defaults to "". ImageStaticRemoteURL *string - // Whether this emoji should be disabled (not shown) on this instance; defaults to false. + + // Whether this emoji should be disabled (not + // shown) on this instance; defaults to false. Disabled *bool - // Whether this emoji should be visible in the instance's emoji picker; defaults to true. + + // Whether this emoji should be visible in + // the instance's emoji picker; defaults to true. VisibleInPicker *bool - // ID of the category this emoji should be placed in; defaults to "". + + // ID of the category this emoji + // should be placed in; defaults to "". CategoryID *string } diff --git a/internal/media/util.go b/internal/media/util.go index 1595da6d7..296bdb883 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -37,6 +37,5 @@ func newHdrBuf(fileSize int) []byte { if fileSize > 0 && fileSize < bufSize { bufSize = fileSize } - return make([]byte, bufSize) } diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 556f4d91f..8eec1f9dd 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -111,7 +111,7 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.tc, suite.federator, filter) + common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, filter) suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index ea6abed6e..61e88501f 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -19,10 +19,12 @@ package account import ( "context" + "errors" "fmt" "io" "mime/multipart" + "codeberg.org/gruf/go-bytesize" "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -203,9 +205,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.Avatar != nil && form.Avatar.Size != 0 { - avatarInfo, err := p.UpdateAvatar(ctx, form.Avatar, nil, account.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + avatarInfo, errWithCode := p.UpdateAvatar(ctx, + account, + form.Avatar, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo @@ -213,9 +219,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.Header != nil && form.Header.Size != 0 { - headerInfo, err := p.UpdateHeader(ctx, form.Header, nil, account.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + headerInfo, errWithCode := p.UpdateHeader(ctx, + account, + form.Header, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo @@ -316,35 +326,33 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form // for this to become the account's new avatar. func (p *Processor) UpdateAvatar( ctx context.Context, + account *gtsmodel.Account, avatar *multipart.FileHeader, description *string, - accountID string, -) (*gtsmodel.MediaAttachment, error) { - maxImageSize := config.GetMediaImageMaxSize() - if avatar.Size > int64(maxImageSize) { - return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", avatar.Size, maxImageSize) +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + max := config.GetMediaImageMaxSize() + if sz := bytesize.Size(avatar.Size); sz > max { + text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := avatar.Open() return f, avatar.Size, err } - // Process the media attachment and load it immediately. - media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ - Avatar: util.Ptr(true), - Description: description, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - return attachment, nil + // Write to instance storage. + return p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Avatar: util.Ptr(true), + Description: description, + }, + ) } // UpdateHeader does the dirty work of checking the header @@ -353,33 +361,31 @@ func (p *Processor) UpdateAvatar( // for this to become the account's new header. func (p *Processor) UpdateHeader( ctx context.Context, + account *gtsmodel.Account, header *multipart.FileHeader, description *string, - accountID string, -) (*gtsmodel.MediaAttachment, error) { - maxImageSize := config.GetMediaImageMaxSize() - if header.Size > int64(maxImageSize) { - return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", header.Size, maxImageSize) +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + max := config.GetMediaImageMaxSize() + if sz := bytesize.Size(header.Size); sz > max { + text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := header.Open() return f, header.Size, err } - // Process the media attachment and load it immediately. - media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ - Header: util.Ptr(true), - Description: description, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - return attachment, nil + // Write to instance storage. + return p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Header: util.Ptr(true), + Description: description, + }, + ) } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 3093b3e36..170298ca5 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -20,20 +20,26 @@ package admin import ( "github.com/superseriousbusiness/gotosocial/internal/cleaner" "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type Processor struct { - state *state.State - cleaner *cleaner.Cleaner - converter *typeutils.Converter - mediaManager *media.Manager - transportController transport.Controller - emailSender email.Sender + // common processor logic + c *common.Processor + + state *state.State + cleaner *cleaner.Cleaner + converter *typeutils.Converter + federator *federation.Federator + media *media.Manager + transport transport.Controller + email email.Sender // admin Actions currently // undergoing processing @@ -46,21 +52,24 @@ func (p *Processor) Actions() *Actions { // New returns a new admin processor. func New( + common *common.Processor, state *state.State, cleaner *cleaner.Cleaner, + federator *federation.Federator, converter *typeutils.Converter, mediaManager *media.Manager, transportController transport.Controller, emailSender email.Sender, ) Processor { return Processor{ - state: state, - cleaner: cleaner, - converter: converter, - mediaManager: mediaManager, - transportController: transportController, - emailSender: emailSender, - + c: common, + state: state, + cleaner: cleaner, + converter: converter, + federator: federator, + media: mediaManager, + transport: transportController, + email: emailSender, actions: &Actions{ r: make(map[string]*gtsmodel.AdminAction), state: state, diff --git a/internal/processing/admin/debug_apurl.go b/internal/processing/admin/debug_apurl.go index db3c60d0c..dbf337dc3 100644 --- a/internal/processing/admin/debug_apurl.go +++ b/internal/processing/admin/debug_apurl.go @@ -78,7 +78,7 @@ func (p *Processor) DebugAPUrl( } // All looks fine. Prepare the transport and (signed) GET request. - tsport, err := p.transportController.NewTransportForUsername(ctx, adminAcct.Username) + tsport, err := p.transport.NewTransportForUsername(ctx, adminAcct.Username) if err != nil { err = gtserror.Newf("error creating transport: %w", err) return nil, gtserror.NewErrorInternalError(err, err.Error()) diff --git a/internal/processing/admin/email.go b/internal/processing/admin/email.go index fda60754c..949be6e4b 100644 --- a/internal/processing/admin/email.go +++ b/internal/processing/admin/email.go @@ -55,7 +55,7 @@ func (p *Processor) EmailTest( InstanceName: instance.Title, } - if err := p.emailSender.SendTestEmail(toAddress, testData); err != nil { + if err := p.email.SendTestEmail(toAddress, testData); err != nil { if gtserror.IsSMTP(err) { // An error occurred during the SMTP part. // We should indicate this to the caller, as diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index dcdf77642..4d1b464d3 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -31,7 +31,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -41,64 +40,21 @@ func (p *Processor) EmojiCreate( account *gtsmodel.Account, form *apimodel.EmojiCreateRequest, ) (*apimodel.Emoji, gtserror.WithCode) { - // Ensure emoji with this shortcode - // doesn't already exist on the instance. - maybeExisting, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, form.Shortcode, "") - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("error checking existence of emoji with shortcode %s: %w", form.Shortcode, err) - return nil, gtserror.NewErrorInternalError(err) - } - if maybeExisting != nil { - err := fmt.Errorf("emoji with shortcode %s already exists", form.Shortcode) - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - - // Prepare data function for emoji processing - // (just read data from the submitted form). - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + // Simply read provided form data for emoji data source. + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := form.Image.Open() return f, form.Image.Size, err } - // If category was supplied on the form, - // ensure the category exists and provide - // it as additional info to emoji processing. - var ai *media.AdditionalEmojiInfo - if form.CategoryName != "" { - category, err := p.getOrCreateEmojiCategory(ctx, form.CategoryName) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - ai = &media.AdditionalEmojiInfo{ - CategoryID: &category.ID, - } - } - - // Generate new emoji ID and URI. - emojiID, err := id.NewRandomULID() - if err != nil { - err := gtserror.Newf("error creating id for new emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - emojiURI := uris.URIForEmoji(emojiID) - - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, form.Shortcode, emojiID, emojiURI, ai, false, + // Attempt to create the new local emoji. + emoji, errWithCode := p.createEmoji(ctx, + form.Shortcode, + form.CategoryName, + data, ) - if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Complete processing immediately. - emoji, err := processingEmoji.LoadEmoji(ctx) - if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if errWithCode != nil { + return nil, errWithCode } apiEmoji, err := p.converter.EmojiToAPIEmoji(ctx, emoji) @@ -110,53 +66,6 @@ func (p *Processor) EmojiCreate( return &apiEmoji, nil } -// emojisGetFilterParams builds extra -// query parameters to return as part -// of an Emojis pageable response. -// -// The returned string will look like: -// -// "filter=domain:all,enabled,shortcode:example" -func emojisGetFilterParams( - shortcode string, - domain string, - includeDisabled bool, - includeEnabled bool, -) string { - var filterBuilder strings.Builder - filterBuilder.WriteString("filter=") - - switch domain { - case "", "local": - // Local emojis only. - filterBuilder.WriteString("domain:local") - - case db.EmojiAllDomains: - // Local or remote. - filterBuilder.WriteString("domain:all") - - default: - // Specific domain only. - filterBuilder.WriteString("domain:" + domain) - } - - if includeDisabled != includeEnabled { - if includeDisabled { - filterBuilder.WriteString(",disabled") - } - if includeEnabled { - filterBuilder.WriteString(",enabled") - } - } - - if shortcode != "" { - // Specific shortcode only. - filterBuilder.WriteString(",shortcode:" + shortcode) - } - - return filterBuilder.String() -} - // EmojisGet returns an admin view of custom // emojis, filtered with the given parameters. func (p *Processor) EmojisGet( @@ -287,21 +196,24 @@ func (p *Processor) EmojiDelete( // given id, using the provided form parameters. func (p *Processor) EmojiUpdate( ctx context.Context, - id string, + emojiID string, form *apimodel.EmojiUpdateRequest, ) (*apimodel.AdminEmoji, gtserror.WithCode) { - emoji, err := p.state.DB.GetEmojiByID(ctx, id) + + // Get the emoji with given ID from the database. + emoji, err := p.state.DB.GetEmojiByID(ctx, emojiID) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error: %w", err) + err := gtserror.Newf("error fetching emoji from db: %w", err) return nil, gtserror.NewErrorInternalError(err) } + // Check found. if emoji == nil { - err := gtserror.Newf("no emoji with id %s found in the db", id) - return nil, gtserror.NewErrorNotFound(err) + const text = "emoji not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) } - switch t := form.Type; t { + switch form.Type { case apimodel.EmojiUpdateCopy: return p.emojiUpdateCopy(ctx, emoji, form.Shortcode, form.CategoryName) @@ -313,8 +225,8 @@ func (p *Processor) EmojiUpdate( return p.emojiUpdateModify(ctx, emoji, form.Image, form.CategoryName) default: - err := fmt.Errorf("unrecognized emoji action type %s", t) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + const text = "unrecognized emoji update action type" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } } @@ -342,56 +254,6 @@ func (p *Processor) EmojiCategoriesGet( return apiCategories, nil } -/* - UTIL FUNCTIONS -*/ - -// getOrCreateEmojiCategory either gets an existing -// category with the given name from the database, -// or, if the category doesn't yet exist, it creates -// the category and then returns it. -func (p *Processor) getOrCreateEmojiCategory( - ctx context.Context, - name string, -) (*gtsmodel.EmojiCategory, error) { - category, err := p.state.DB.GetEmojiCategoryByName(ctx, name) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.Newf( - "database error trying get emoji category %s: %w", - name, err, - ) - } - - if category != nil { - // We had it already. - return category, nil - } - - // We don't have the category yet, - // create it with the given name. - categoryID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.Newf( - "error generating id for new emoji category %s: %w", - name, err, - ) - } - - category = >smodel.EmojiCategory{ - ID: categoryID, - Name: name, - } - - if err := p.state.DB.PutEmojiCategory(ctx, category); err != nil { - return nil, gtserror.Newf( - "db error putting new emoji category %s: %w", - name, err, - ) - } - - return category, nil -} - // emojiUpdateCopy copies and stores the given // *remote* emoji as a *local* emoji, preserving // the same image, and using the provided shortcode. @@ -400,99 +262,56 @@ func (p *Processor) getOrCreateEmojiCategory( // emoji already stored in the database + storage. func (p *Processor) emojiUpdateCopy( ctx context.Context, - targetEmoji *gtsmodel.Emoji, + target *gtsmodel.Emoji, shortcode *string, - category *string, + categoryName *string, ) (*apimodel.AdminEmoji, gtserror.WithCode) { - if targetEmoji.IsLocal() { - err := fmt.Errorf("emoji %s is not a remote emoji, cannot copy it to local", targetEmoji.ID) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + if target.IsLocal() { + const text = "target emoji is not remote; cannot copy to local" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - if shortcode == nil { - err := errors.New("no shortcode provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } + // Ensure target emoji is locally cached. + target, err := p.federator.RefreshEmoji( + ctx, + target, - sc := *shortcode - if sc == "" { - err := errors.New("empty shortcode provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + // no changes we want to make. + media.AdditionalEmojiInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching emoji %s: %w", target.ImageRemoteURL, err) + return nil, gtserror.NewErrorNotFound(err) } - // Ensure we don't already have an emoji - // stored locally with this shortcode. - maybeExisting, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, sc, "") - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error checking for emoji with shortcode %s: %w", sc, err) - return nil, gtserror.NewErrorInternalError(err) - } - - if maybeExisting != nil { - err := fmt.Errorf("emoji with shortcode %s already exists on this instance", sc) - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - - // We don't have an emoji with this - // shortcode yet! Prepare to create it. - // Data function for copying just streams media // out of storage into an additional location. // // This means that data for the copy persists even // if the remote copied emoji gets deleted at some point. data := func(ctx context.Context) (io.ReadCloser, int64, error) { - rc, err := p.state.Storage.GetStream(ctx, targetEmoji.ImagePath) - return rc, int64(targetEmoji.ImageFileSize), err + rc, err := p.state.Storage.GetStream(ctx, target.ImagePath) + return rc, int64(target.ImageFileSize), err } - // Generate new emoji ID and URI. - emojiID, err := id.NewRandomULID() - if err != nil { - err := gtserror.Newf("error creating id for new emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - emojiURI := uris.URIForEmoji(emojiID) - - // If category was supplied, ensure the - // category exists and provide it as - // additional info to emoji processing. - var ai *media.AdditionalEmojiInfo - if category != nil && *category != "" { - category, err := p.getOrCreateEmojiCategory(ctx, *category) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - ai = &media.AdditionalEmojiInfo{ - CategoryID: &category.ID, - } - } - - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, sc, emojiID, emojiURI, ai, false, + // Attempt to create the new local emoji. + emoji, errWithCode := p.createEmoji(ctx, + util.PtrValueOr(shortcode, ""), + util.PtrValueOr(categoryName, ""), + data, ) + if errWithCode != nil { + return nil, errWithCode + } + + apiEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji) if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) + err := gtserror.Newf("error converting emoji: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Complete processing immediately. - newEmoji, err := processingEmoji.LoadEmoji(ctx) - if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, newEmoji) - if err != nil { - err := gtserror.Newf("error converting emoji %s to admin emoji: %w", newEmoji.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return adminEmoji, nil + return apiEmoji, nil } // emojiUpdateDisable marks the given *remote* @@ -521,7 +340,7 @@ func (p *Processor) emojiUpdateDisable( adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji) if err != nil { - err := gtserror.Newf("error converting emoji %s to admin emoji: %w", emoji.ID, err) + err := gtserror.Newf("error converting emoji: %w", err) return nil, gtserror.NewErrorInternalError(err) } @@ -541,104 +360,222 @@ func (p *Processor) emojiUpdateModify( ctx context.Context, emoji *gtsmodel.Emoji, image *multipart.FileHeader, - category *string, + categoryName *string, ) (*apimodel.AdminEmoji, gtserror.WithCode) { if !emoji.IsLocal() { - err := fmt.Errorf("emoji %s is not a local emoji, cannot update it via this endpoint", emoji.ID) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + const text = "cannot modify remote emoji" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } // Ensure there's actually something to update. - if image == nil && category == nil { - err := errors.New("neither new category nor new image set, cannot update") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) + if image == nil && categoryName == nil { + const text = "no changes were provided" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } - // Only update category - // if it's changed. - var ( - newCategory *gtsmodel.EmojiCategory - newCategoryID string - updateCategoryID bool - ) - - if category != nil { - catName := *category - if catName != "" { - // Set new category. - var err error - newCategory, err = p.getOrCreateEmojiCategory(ctx, catName) - if err != nil { - err := gtserror.Newf("error getting or creating category: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if categoryName != nil { + if *categoryName != "" { + // A category was provided, get / create relevant emoji category. + category, errWithCode := p.mustGetEmojiCategory(ctx, *categoryName) + if errWithCode != nil { + return nil, errWithCode } - newCategoryID = newCategory.ID + if category.ID == emoji.CategoryID { + // There was no change, + // indicate this by unsetting + // the category name pointer. + categoryName = nil + } else { + // Update emoji category. + emoji.CategoryID = category.ID + emoji.Category = category + } } else { - // Clear existing category. - newCategoryID = "" + // Emoji category was unset. + emoji.CategoryID = "" + emoji.Category = nil } - - updateCategoryID = emoji.CategoryID != newCategoryID } - // Only update image - // if one is provided. - var updateImage bool - if image != nil && image.Size != 0 { - updateImage = true - } + // Check whether any image changes were requested. + imageUpdated := (image != nil && image.Size > 0) - if updateCategoryID && !updateImage { - // Only updating category; we only - // need to do a db update for this. - emoji.CategoryID = newCategoryID - emoji.Category = newCategory + if !imageUpdated && categoryName != nil { + // Only updating category; only a single database update required. if err := p.state.DB.UpdateEmoji(ctx, emoji, "category_id"); err != nil { - err := gtserror.Newf("db error updating emoji %s: %w", emoji.ID, err) + err := gtserror.Newf("error updating emoji in db: %w", err) return nil, gtserror.NewErrorInternalError(err) } - } else if updateImage { + } else if imageUpdated { + var err error + // Updating image and maybe categoryID. // We can do both at the same time :) - // Set data function to provided image. - data := func(ctx context.Context) (io.ReadCloser, int64, error) { - i, err := image.Open() - return i, image.Size, err + // Simply read provided form data for emoji data source. + data := func(_ context.Context) (io.ReadCloser, int64, error) { + f, err := image.Open() + return f, image.Size, err } - // If necessary, include - // update to categoryID too. - var ai *media.AdditionalEmojiInfo - if updateCategoryID { - ai = &media.AdditionalEmojiInfo{ - CategoryID: &newCategoryID, - } - } + // Prepare emoji model for recache from new data. + processing := p.media.RecacheEmoji(emoji, data) - // Begin media processing. - processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, - data, emoji.Shortcode, emoji.ID, emoji.URI, ai, false, - ) + // Load to trigger update + write. + emoji, err = processing.Load(ctx) if err != nil { - err := gtserror.Newf("error processing emoji: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Replace emoji ptr with newly-processed version. - emoji, err = processingEmoji.LoadEmoji(ctx) - if err != nil { - err := gtserror.Newf("error loading emoji: %w", err) + err := gtserror.Newf("error processing emoji %s: %w", emoji.Shortcode, err) return nil, gtserror.NewErrorInternalError(err) } } adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji) if err != nil { - err := gtserror.Newf("error converting emoji %s to admin emoji: %w", emoji.ID, err) + err := gtserror.Newf("error converting emoji: %w", err) return nil, gtserror.NewErrorInternalError(err) } return adminEmoji, nil } + +// createEmoji will create a new local emoji +// with the given shortcode, attached category +// name (if any) and data source function. +func (p *Processor) createEmoji( + ctx context.Context, + shortcode string, + categoryName string, + data media.DataFunc, +) ( + *gtsmodel.Emoji, + gtserror.WithCode, +) { + // Validate shortcode. + if shortcode == "" { + const text = "empty shortcode name" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Look for an existing local emoji with shortcode to ensure this is new. + existing, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, shortcode, "") + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching emoji from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } else if existing != nil { + const text = "emoji with shortcode already exists" + return nil, gtserror.NewErrorConflict(errors.New(text), text) + } + + var categoryID *string + + if categoryName != "" { + // A category was provided, get / create relevant emoji category. + category, errWithCode := p.mustGetEmojiCategory(ctx, categoryName) + if errWithCode != nil { + return nil, errWithCode + } + + // Set category ID for emoji. + categoryID = &category.ID + } + + // Store to instance storage. + return p.c.StoreLocalEmoji( + ctx, + shortcode, + data, + media.AdditionalEmojiInfo{ + CategoryID: categoryID, + }, + ) +} + +// mustGetEmojiCategory either gets an existing +// category with the given name from the database, +// or, if the category doesn't yet exist, it creates +// the category and then returns it. +func (p *Processor) mustGetEmojiCategory( + ctx context.Context, + name string, +) ( + *gtsmodel.EmojiCategory, + gtserror.WithCode, +) { + // Look for an existing emoji category with name. + category, err := p.state.DB.GetEmojiCategoryByName(ctx, name) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching emoji category from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if category != nil { + // We had it already. + return category, nil + } + + // Create new ID. + id := id.NewULID() + + // Prepare new category for insertion. + category = >smodel.EmojiCategory{ + ID: id, + Name: name, + } + + // Insert new category into the database. + err = p.state.DB.PutEmojiCategory(ctx, category) + if err != nil { + err := gtserror.Newf("error inserting emoji category into db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return category, nil +} + +// emojisGetFilterParams builds extra +// query parameters to return as part +// of an Emojis pageable response. +// +// The returned string will look like: +// +// "filter=domain:all,enabled,shortcode:example" +func emojisGetFilterParams( + shortcode string, + domain string, + includeDisabled bool, + includeEnabled bool, +) string { + var filterBuilder strings.Builder + filterBuilder.WriteString("filter=") + + switch domain { + case "", "local": + // Local emojis only. + filterBuilder.WriteString("domain:local") + + case db.EmojiAllDomains: + // Local or remote. + filterBuilder.WriteString("domain:all") + + default: + // Specific domain only. + filterBuilder.WriteString("domain:" + domain) + } + + if includeDisabled != includeEnabled { + if includeDisabled { + filterBuilder.WriteString(",disabled") + } + if includeEnabled { + filterBuilder.WriteString(",enabled") + } + } + + if shortcode != "" { + // Specific shortcode only. + filterBuilder.WriteString(",shortcode:" + shortcode) + } + + return filterBuilder.String() +} diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go index 13dcb7d28..edbcbe349 100644 --- a/internal/processing/admin/media.go +++ b/internal/processing/admin/media.go @@ -28,7 +28,7 @@ import ( // MediaRefetch forces a refetch of remote emojis. func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode { - transport, err := p.transportController.NewTransportForUsername(ctx, requestingAccount.Username) + transport, err := p.transport.NewTransportForUsername(ctx, requestingAccount.Username) if err != nil { err = fmt.Errorf("error getting transport for user %s during media refetch request: %w", requestingAccount.Username, err) return gtserror.NewErrorInternalError(err) @@ -36,7 +36,7 @@ func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmode go func() { log.Info(ctx, "starting emoji refetch") - refetched, err := p.mediaManager.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia) + refetched, err := p.media.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia) if err != nil { log.Errorf(ctx, "error refetching emojis: %s", err) } else { diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index e4a49cc45..942cecc59 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -20,6 +20,7 @@ package common import ( "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -29,6 +30,7 @@ import ( // processing subsection of the codebase. type Processor struct { state *state.State + media *media.Manager converter *typeutils.Converter federator *federation.Federator filter *visibility.Filter @@ -37,12 +39,14 @@ type Processor struct { // New returns a new Processor instance. func New( state *state.State, + media *media.Manager, converter *typeutils.Converter, federator *federation.Federator, filter *visibility.Filter, ) Processor { return Processor{ state: state, + media: media, converter: converter, federator: federator, filter: filter, diff --git a/internal/processing/common/media.go b/internal/processing/common/media.go new file mode 100644 index 000000000..7baf30345 --- /dev/null +++ b/internal/processing/common/media.go @@ -0,0 +1,98 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// StoreLocalMedia is a wrapper around CreateMedia() and +// ProcessingMedia{}.Load() with appropriate error responses. +func (p *Processor) StoreLocalMedia( + ctx context.Context, + accountID string, + data media.DataFunc, + info media.AdditionalMediaInfo, +) ( + *gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + // Create a new processing media attachment. + processing, err := p.media.CreateMedia(ctx, + accountID, + data, + info, + ) + if err != nil { + err := gtserror.Newf("error creating media: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Immediately trigger write to storage. + attachment, err := processing.Load(ctx) + if err != nil { + const text = "error processing emoji" + err := gtserror.Newf("error processing media: %w", err) + return nil, gtserror.NewErrorUnprocessableEntity(err, text) + } else if attachment.Type == gtsmodel.FileTypeUnknown { + text := fmt.Sprintf("could not process %s type media", attachment.File.ContentType) + return nil, gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + return attachment, nil +} + +// StoreLocalMedia is a wrapper around CreateMedia() and +// ProcessingMedia{}.Load() with appropriate error responses. +func (p *Processor) StoreLocalEmoji( + ctx context.Context, + shortcode string, + data media.DataFunc, + info media.AdditionalEmojiInfo, +) ( + *gtsmodel.Emoji, + gtserror.WithCode, +) { + // Create a new processing emoji media. + processing, err := p.media.CreateEmoji(ctx, + shortcode, + "", // domain = "" -> local + data, + info, + ) + if err != nil { + err := gtserror.Newf("error creating emoji: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Immediately write to storage. + emoji, err := processing.Load(ctx) + if err != nil { + const text = "error processing emoji" + err := gtserror.Newf("error processing emoji %s: %w", shortcode, err) + return nil, gtserror.NewErrorUnprocessableEntity(err, text) + } + + return emoji, nil +} diff --git a/internal/processing/instance.go b/internal/processing/instance.go index a93936425..a9be6db1d 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -246,9 +246,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe if form.Avatar != nil && form.Avatar.Size != 0 { // Process instance avatar image + description. - avatarInfo, err := p.account.UpdateAvatar(ctx, form.Avatar, form.AvatarDescription, instanceAcc.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err, "error processing avatar") + avatarInfo, errWithCode := p.account.UpdateAvatar(ctx, + instanceAcc, + form.Avatar, + form.AvatarDescription, + ) + if errWithCode != nil { + return nil, errWithCode } instanceAcc.AvatarMediaAttachmentID = avatarInfo.ID instanceAcc.AvatarMediaAttachment = avatarInfo @@ -264,9 +268,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe if form.Header != nil && form.Header.Size != 0 { // process instance header image - headerInfo, err := p.account.UpdateHeader(ctx, form.Header, nil, instanceAcc.ID) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err, "error processing header") + headerInfo, errWithCode := p.account.UpdateHeader(ctx, + instanceAcc, + form.Header, + nil, + ) + if errWithCode != nil { + return nil, errWithCode } instanceAcc.HeaderMediaAttachmentID = headerInfo.ID instanceAcc.HeaderMediaAttachment = headerInfo diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index fe20457b4..0dbe997de 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -30,7 +30,7 @@ import ( // Create creates a new media attachment belonging to the given account, using the request form. func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) { - data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { + data := func(_ context.Context) (io.ReadCloser, int64, error) { f, err := form.File.Open() return f, form.File.Size, err } @@ -41,19 +41,18 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - // process the media attachment and load it immediately - media := p.mediaManager.PreProcessMedia(data, account.ID, &media.AdditionalMediaInfo{ - Description: &form.Description, - FocusX: &focusX, - FocusY: &focusY, - }) - - attachment, err := media.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } else if attachment.Type == gtsmodel.FileTypeUnknown { - err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + // Create local media and write to instance storage. + attachment, errWithCode := p.c.StoreLocalMedia(ctx, + account.ID, + data, + media.AdditionalMediaInfo{ + Description: &form.Description, + FocusX: &focusX, + FocusY: &focusY, + }, + ) + if errWithCode != nil { + return nil, errWithCode } apiAttachment, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 28f5e6464..7ba549029 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -19,14 +19,14 @@ package media import ( "context" + "errors" "fmt" - "io" "net/url" "strings" "time" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -38,7 +38,7 @@ import ( // to the caller via an io.reader embedded in *apimodel.Content. func (p *Processor) GetFile( ctx context.Context, - requestingAccount *gtsmodel.Account, + requester *gtsmodel.Account, form *apimodel.GetContentRequestForm, ) (*apimodel.Content, gtserror.WithCode) { // parse the form fields @@ -69,13 +69,13 @@ func (p *Processor) GetFile( } // make sure the requesting account and the media account don't block each other - if requestingAccount != nil { - blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, owningAccountID) + if requester != nil { + blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, owningAccountID) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requestingAccount.ID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requester.ID, err)) } if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requester.ID)) } } @@ -83,17 +83,254 @@ func (p *Processor) GetFile( // so we need to take different steps depending on the media type being requested switch mediaType { case media.TypeEmoji: - return p.getEmojiContent(ctx, wantedMediaID, owningAccountID, mediaSize) + return p.getEmojiContent(ctx, + owningAccountID, + wantedMediaID, + mediaSize, + ) case media.TypeAttachment, media.TypeHeader, media.TypeAvatar: - return p.getAttachmentContent(ctx, requestingAccount, wantedMediaID, owningAccountID, mediaSize) + return p.getAttachmentContent(ctx, + requester, + owningAccountID, + wantedMediaID, + mediaSize, + ) default: return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not recognized", mediaType)) } } -/* - UTIL FUNCTIONS -*/ +func (p *Processor) getAttachmentContent( + ctx context.Context, + requester *gtsmodel.Account, + ownerID string, + mediaID string, + sizeStr media.Size, +) ( + *apimodel.Content, + gtserror.WithCode, +) { + // Search for media with given ID in the database. + attach, err := p.state.DB.GetAttachmentByID(ctx, mediaID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching media from database: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if attach == nil { + const text = "media not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } + + // Ensure the 'owner' owns media. + if attach.AccountID != ownerID { + const text = "media was not owned by passed account id" + return nil, gtserror.NewErrorNotFound(errors.New(text) /* no help text! */) + } + + var remoteURL *url.URL + if attach.RemoteURL != "" { + + // Parse media remote URL to valid URL object. + remoteURL, err = url.Parse(attach.RemoteURL) + if err != nil { + err := gtserror.Newf("invalid media remote url %s: %w", attach.RemoteURL, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // Uknown file types indicate no *locally* + // stored data we can serve. Handle separately. + if attach.Type == gtsmodel.FileTypeUnknown { + if remoteURL == nil { + err := gtserror.Newf("missing remote url for unknown type media %s: %w", attach.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // If this is an "Unknown" file type, ie., one we + // tried to process and couldn't, or one we refused + // to process because it wasn't supported, then we + // can skip a lot of steps here by simply forwarding + // the request to the remote URL. + url := &storage.PresignedURL{ + URL: remoteURL, + + // We might manage to cache the media + // at some point, so set a low-ish expiry. + Expiry: time.Now().Add(2 * time.Hour), + } + + return &apimodel.Content{URL: url}, nil + } + + var requestUser string + + if requester != nil { + // Set requesting acc username. + requestUser = requester.Username + } + + // Ensure that stored media is cached. + // (this handles local media / recaches). + attach, err = p.federator.RefreshMedia( + ctx, + requestUser, + attach, + media.AdditionalMediaInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching media: %w", err) + return nil, gtserror.NewErrorNotFound(err) + } + + // Start preparing API content model. + apiContent := &apimodel.Content{ + ContentUpdated: attach.UpdatedAt, + } + + // Retrieve appropriate + // size file from storage. + switch sizeStr { + + case media.SizeOriginal: + apiContent.ContentType = attach.File.ContentType + apiContent.ContentLength = int64(attach.File.FileSize) + return p.getContent(ctx, + attach.File.Path, + apiContent, + ) + + case media.SizeSmall: + apiContent.ContentType = attach.Thumbnail.ContentType + apiContent.ContentLength = int64(attach.Thumbnail.FileSize) + return p.getContent(ctx, + attach.Thumbnail.Path, + apiContent, + ) + + default: + const text = "invalid media attachment size" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } +} + +func (p *Processor) getEmojiContent( + ctx context.Context, + + ownerID string, + emojiID string, + sizeStr media.Size, +) ( + *apimodel.Content, + gtserror.WithCode, +) { + // Reconstruct static emoji image URL to search for it. + // As refreshed emojis use a newly generated path ID to + // differentiate them (cache-wise) from the original. + staticURL := uris.URIForAttachment( + ownerID, + string(media.TypeEmoji), + string(media.SizeStatic), + emojiID, + "png", + ) + + // Search for emoji with given static URL in the database. + emoji, err := p.state.DB.GetEmojiByStaticURL(ctx, staticURL) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching emoji from database: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if emoji == nil { + const text = "emoji not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } + + if *emoji.Disabled { + const text = "emoji has been disabled" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } + + // Ensure that stored emoji is cached. + // (this handles local emoji / recaches). + emoji, err = p.federator.RefreshEmoji( + ctx, + emoji, + media.AdditionalEmojiInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching emoji: %w", err) + return nil, gtserror.NewErrorNotFound(err) + } + + // Start preparing API content model. + apiContent := &apimodel.Content{} + + // Retrieve appropriate + // size file from storage. + switch sizeStr { + + case media.SizeOriginal: + apiContent.ContentType = emoji.ImageContentType + apiContent.ContentLength = int64(emoji.ImageFileSize) + return p.getContent(ctx, + emoji.ImagePath, + apiContent, + ) + + case media.SizeStatic: + apiContent.ContentType = emoji.ImageStaticContentType + apiContent.ContentLength = int64(emoji.ImageStaticFileSize) + return p.getContent(ctx, + emoji.ImageStaticPath, + apiContent, + ) + + default: + const text = "invalid media attachment size" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } +} + +// getContent performs the final file fetching of +// stored content at path in storage. This is +// populated in the apimodel.Content{} and returned. +// (note: this also handles un-proxied S3 storage). +func (p *Processor) getContent( + ctx context.Context, + path string, + content *apimodel.Content, +) ( + *apimodel.Content, + gtserror.WithCode, +) { + // If running on S3 storage with proxying disabled then + // just fetch pre-signed URL instead of the content. + if url := p.state.Storage.URL(ctx, path); url != nil { + content.URL = url + return content, nil + } + + // Fetch file stream for the stored media at path. + rc, err := p.state.Storage.GetStream(ctx, path) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("error getting file %s from storage: %w", path, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Ensure found. + if rc == nil { + const text = "file not found" + return nil, gtserror.NewErrorNotFound(errors.New(text), text) + } + + // Return with stream. + content.Content = rc + return content, nil +} func parseType(s string) (media.Type, error) { switch s { @@ -120,198 +357,3 @@ func parseSize(s string) (media.Size, error) { } return "", fmt.Errorf("%s not a recognized media.Size", s) } - -func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount *gtsmodel.Account, wantedMediaID string, owningAccountID string, mediaSize media.Size) (*apimodel.Content, gtserror.WithCode) { - // retrieve attachment from the database and do basic checks on it - a, err := p.state.DB.GetAttachmentByID(ctx, wantedMediaID) - if err != nil { - err = gtserror.Newf("attachment %s could not be taken from the db: %w", wantedMediaID, err) - return nil, gtserror.NewErrorNotFound(err) - } - - if a.AccountID != owningAccountID { - err = gtserror.Newf("attachment %s is not owned by %s", wantedMediaID, owningAccountID) - return nil, gtserror.NewErrorNotFound(err) - } - - // If this is an "Unknown" file type, ie., one we - // tried to process and couldn't, or one we refused - // to process because it wasn't supported, then we - // can skip a lot of steps here by simply forwarding - // the request to the remote URL. - if a.Type == gtsmodel.FileTypeUnknown { - remoteURL, err := url.Parse(a.RemoteURL) - if err != nil { - err = gtserror.Newf("error parsing remote URL of 'Unknown'-type attachment for redirection: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - url := &storage.PresignedURL{ - URL: remoteURL, - // We might manage to cache the media - // at some point, so set a low-ish expiry. - Expiry: time.Now().Add(2 * time.Hour), - } - - return &apimodel.Content{URL: url}, nil - } - - if !*a.Cached { - // if we don't have it cached, then we can assume two things: - // 1. this is remote media, since local media should never be uncached - // 2. we need to fetch it again using a transport and the media manager - remoteMediaIRI, err := url.Parse(a.RemoteURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %w", a.RemoteURL, err)) - } - - // use an empty string as requestingUsername to use the instance account, unless the request for this - // media has been http signed, then use the requesting account to make the request to remote server - var requestingUsername string - if requestingAccount != nil { - requestingUsername = requestingAccount.Username - } - - // Pour one out for tobi's original streamed recache - // (streaming data both to the client and storage). - // Gone and forever missed <3 - // - // [ - // the reason it was removed was because a slow - // client connection could hold open a storage - // recache operation -> holding open a media worker. - // ] - - dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(ctx, requestingUsername) - if err != nil { - return nil, 0, err - } - return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteMediaIRI) - } - - // Start recaching this media with the prepared data function. - processingMedia, err := p.mediaManager.PreProcessMediaRecache(ctx, dataFn, wantedMediaID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %w", err)) - } - - // Load attachment and block until complete - a, err = processingMedia.LoadAttachment(ctx) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %w", err)) - } - } - - var ( - storagePath string - attachmentContent = &apimodel.Content{ - ContentUpdated: a.UpdatedAt, - } - ) - - // get file information from the attachment depending on the requested media size - switch mediaSize { - case media.SizeOriginal: - attachmentContent.ContentType = a.File.ContentType - attachmentContent.ContentLength = int64(a.File.FileSize) - storagePath = a.File.Path - case media.SizeSmall: - attachmentContent.ContentType = a.Thumbnail.ContentType - attachmentContent.ContentLength = int64(a.Thumbnail.FileSize) - storagePath = a.Thumbnail.Path - default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) - } - - // ... so now we can safely return it - return p.retrieveFromStorage(ctx, storagePath, attachmentContent) -} - -func (p *Processor) getEmojiContent(ctx context.Context, fileName string, owningAccountID string, emojiSize media.Size) (*apimodel.Content, gtserror.WithCode) { - emojiContent := &apimodel.Content{} - var storagePath string - - // reconstruct the static emoji image url -- reason - // for using the static URL rather than full size url - // is that static emojis are always encoded as png, - // so this is more reliable than using full size url - imageStaticURL := uris.URIForAttachment( - owningAccountID, - string(media.TypeEmoji), - string(media.SizeStatic), - fileName, - "png", - ) - - e, err := p.state.DB.GetEmojiByStaticURL(ctx, imageStaticURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %w", fileName, err)) - } - - if *e.Disabled { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", fileName)) - } - - if !*e.Cached { - // if we don't have it cached, then we can assume two things: - // 1. this is remote emoji, since local emoji should never be uncached - // 2. we need to fetch it again using a transport and the media manager - remoteURL, err := url.Parse(e.ImageRemoteURL) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote emoji iri %s: %w", e.ImageRemoteURL, err)) - } - - dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(ctx, "") - if err != nil { - return nil, 0, err - } - return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteURL) - } - - // Start recaching this emoji with the prepared data function. - processingEmoji, err := p.mediaManager.PreProcessEmojiRecache(ctx, dataFn, e.ID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching emoji: %w", err)) - } - - // Load attachment and block until complete - e, err = processingEmoji.LoadEmoji(ctx) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached emoji: %w", err)) - } - } - - switch emojiSize { - case media.SizeOriginal: - emojiContent.ContentType = e.ImageContentType - emojiContent.ContentLength = int64(e.ImageFileSize) - storagePath = e.ImagePath - case media.SizeStatic: - emojiContent.ContentType = e.ImageStaticContentType - emojiContent.ContentLength = int64(e.ImageStaticFileSize) - storagePath = e.ImageStaticPath - default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", emojiSize)) - } - - return p.retrieveFromStorage(ctx, storagePath, emojiContent) -} - -func (p *Processor) retrieveFromStorage(ctx context.Context, storagePath string, content *apimodel.Content) (*apimodel.Content, gtserror.WithCode) { - // If running on S3 storage with proxying disabled then - // just fetch a pre-signed URL instead of serving the content. - if url := p.state.Storage.URL(ctx, storagePath); url != nil { - content.URL = url - return content, nil - } - - reader, err := p.state.Storage.GetStream(ctx, storagePath) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) - } - - content.Content = reader - return content, nil -} diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go index 22c455920..76ed68f5a 100644 --- a/internal/processing/media/media.go +++ b/internal/processing/media/media.go @@ -18,24 +18,39 @@ package media import ( + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type Processor struct { + // common processor logic + c *common.Processor + state *state.State converter *typeutils.Converter + federator *federation.Federator mediaManager *media.Manager transportController transport.Controller } // New returns a new media processor. -func New(state *state.State, converter *typeutils.Converter, mediaManager *media.Manager, transportController transport.Controller) Processor { +func New( + common *common.Processor, + state *state.State, + converter *typeutils.Converter, + federator *federation.Federator, + mediaManager *media.Manager, + transportController transport.Controller, +) Processor { return Processor{ + c: common, state: state, converter: converter, + federator: federator, mediaManager: mediaManager, transportController: transportController, } diff --git a/internal/processing/media/media_test.go b/internal/processing/media/media_test.go index 523428140..80f1a7be7 100644 --- a/internal/processing/media/media_test.go +++ b/internal/processing/media/media_test.go @@ -20,8 +20,10 @@ package media_test import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" mediaprocessing "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" @@ -78,7 +80,12 @@ func (suite *MediaStandardTestSuite) SetupTest() { suite.state.Storage = suite.storage suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) - suite.mediaProcessor = mediaprocessing.New(&suite.state, suite.tc, suite.mediaManager, suite.transportController) + + federator := testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) + filter := visibility.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, filter) + + 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") } diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index 847612503..bf6ae4aad 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -57,7 +57,7 @@ func (suite *PollTestSuite) SetupTest() { mediaMgr := media.NewManager(&suite.state) federator := testrig.NewTestFederator(&suite.state, controller, mediaMgr) suite.filter = visibility.NewFilter(&suite.state) - common := common.New(&suite.state, converter, federator, suite.filter) + common := common.New(&suite.state, mediaMgr, converter, federator, suite.filter) suite.polls = polls.New(&common, &suite.state, converter) } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8765819d3..fb6b05d80 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -179,15 +179,15 @@ func NewProcessor( // // Start with sub processors that will // be required by the workers processor. - common := common.New(state, converter, federator, filter) + common := common.New(state, mediaManager, converter, federator, filter) processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) - processor.media = media.New(state, converter, mediaManager, federator.TransportController()) + processor.media = media.New(&common, state, converter, federator, mediaManager, federator.TransportController()) processor.stream = stream.New(state, oauthServer) // Instantiate the rest of the sub // processors + pin them to this struct. processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) - processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) + processor.admin = admin.New(&common, state, cleaner, federator, converter, mediaManager, federator.TransportController(), emailSender) processor.fedi = fedi.New(state, &common, converter, federator, filter) processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 171e4b488..9eba78ec6 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -96,7 +96,7 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.typeConverter, ) - common := common.New(&suite.state, suite.typeConverter, suite.federator, filter) + common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, filter) polls := polls.New(&common, &suite.state, suite.typeConverter) suite.status = status.New(&suite.state, &common, &polls, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 872ea1210..55ec0d167 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -95,7 +95,7 @@ func (d *Driver) PutStream(ctx context.Context, key string, r io.Reader) (int64, return d.Storage.WriteStream(ctx, key, r) } -// Remove attempts to remove the supplied key (and corresponding value) from storage. +// Delete attempts to remove the supplied key (and corresponding value) from storage. func (d *Driver) Delete(ctx context.Context, key string) error { return d.Storage.Remove(ctx, key) } diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 2fb782029..567493673 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -1051,7 +1051,7 @@ func (c *Converter) EmojiToAS(ctx context.Context, e *gtsmodel.Emoji) (vocab.Too emoji.SetActivityStreamsIcon(iconProperty) updatedProp := streams.NewActivityStreamsUpdatedProperty() - updatedProp.Set(e.ImageUpdatedAt) + updatedProp.Set(e.UpdatedAt) emoji.SetActivityStreamsUpdated(updatedProp) return emoji, nil diff --git a/internal/workers/workers.go b/internal/workers/workers.go index 306d9e635..4d2b146b6 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -49,10 +49,6 @@ type Workers struct { // for asynchronous dereferencer jobs. Dereference FnWorkerPool - // Media provides a worker pool for - // asynchronous media processing jobs. - Media FnWorkerPool - // prevent pass-by-value. _ nocopy } @@ -84,10 +80,6 @@ func (w *Workers) Start() { n = 4 * maxprocs w.Dereference.Start(n) log.Infof(nil, "started %d dereference workers", n) - - n = 8 * maxprocs - w.Media.Start(n) - log.Infof(nil, "started %d media workers", n) } // Stop will stop all of the contained worker pools (and global scheduler). @@ -105,9 +97,6 @@ func (w *Workers) Stop() { w.Dereference.Stop() log.Info(nil, "stopped dereference workers") - - w.Media.Stop() - log.Info(nil, "stopped media workers") } // nocopy when embedded will signal linter to diff --git a/testrig/testmodels.go b/testrig/testmodels.go index e3d31b7d2..3db8ef62f 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -739,13 +739,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", ContentType: "image/jpeg", FileSize: 62529, - UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg", ContentType: "image/jpeg", FileSize: 6872, - UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg", RemoteURL: "", }, @@ -788,13 +786,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH7TDVANYKWVE8VVKFPJTJ.gif", ContentType: "image/gif", FileSize: 1109138, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpg", ContentType: "image/jpeg", FileSize: 8803, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpg", RemoteURL: "", }, @@ -840,13 +836,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.gif", ContentType: "video/mp4", FileSize: 2273532, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpg", ContentType: "image/jpeg", FileSize: 5272, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.jpg", RemoteURL: "", }, @@ -889,13 +883,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", ContentType: "image/jpeg", FileSize: 27759, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", ContentType: "image/jpeg", FileSize: 6177, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", RemoteURL: "", }, @@ -938,13 +930,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", ContentType: "image/jpeg", FileSize: 457680, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", ContentType: "image/jpeg", FileSize: 15374, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", RemoteURL: "", }, @@ -987,13 +977,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", ContentType: "image/jpeg", FileSize: 517226, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", ContentType: "image/jpeg", FileSize: 42308, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", RemoteURL: "", }, @@ -1036,13 +1024,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", FileSize: 19310, - UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", ContentType: "image/jpeg", FileSize: 19312, - UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), URL: "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", }, @@ -1085,13 +1071,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", ContentType: "image/jpeg", FileSize: 19310, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", ContentType: "image/jpeg", FileSize: 20395, - UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), URL: "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", }, @@ -1133,13 +1117,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg", ContentType: "image/jpg", FileSize: 5450054, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.jpg", ContentType: "image/jpeg", FileSize: 50820, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.jpg", }, Avatar: util.Ptr(false), @@ -1163,13 +1145,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", ContentType: "image/svg", FileSize: 147819, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", ContentType: "image/jpeg", FileSize: 0, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", }, Avatar: util.Ptr(false), @@ -1193,13 +1173,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", ContentType: "audio/mpeg", FileSize: 147819, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), }, Thumbnail: gtsmodel.Thumbnail{ Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", ContentType: "image/jpeg", FileSize: 0, - UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", }, Avatar: util.Ptr(false), @@ -1228,7 +1206,6 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { ImageStaticContentType: "image/png", ImageFileSize: 36702, ImageStaticFileSize: 10413, - ImageUpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), Disabled: util.Ptr(false), URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ", VisibleInPicker: util.Ptr(true), @@ -1251,7 +1228,6 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { ImageStaticContentType: "image/png", ImageFileSize: 10889, ImageStaticFileSize: 10808, - ImageUpdatedAt: TimeMustParse("2020-03-18T13:12:00+01:00"), Disabled: util.Ptr(false), URI: "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW", VisibleInPicker: util.Ptr(false), diff --git a/testrig/util.go b/testrig/util.go index d5eaedcd5..abc94bf02 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -82,7 +82,6 @@ func StartWorkers(state *state.State, processor *workers.Processor) { state.Workers.Client.Start(1) state.Workers.Federator.Start(1) state.Workers.Dereference.Start(1) - state.Workers.Media.Start(1) } func StopWorkers(state *state.State) { @@ -90,7 +89,6 @@ func StopWorkers(state *state.State) { state.Workers.Client.Stop() state.Workers.Federator.Stop() state.Workers.Dereference.Stop() - state.Workers.Media.Stop() } func StartTimelines(state *state.State, filter *visibility.Filter, converter *typeutils.Converter) {