mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 12:02:26 -05:00 
			
		
		
		
	[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
	
	
This commit is contained in:
		
					parent
					
						
							
								fa710057c8
							
						
					
				
			
			
				commit
				
					
						21bb324156
					
				
			
		
					 48 changed files with 2578 additions and 1926 deletions
				
			
		|  | @ -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) | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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()) | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										3
									
								
								internal/cache/size.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								internal/cache/size.go
									
										
									
									
										vendored
									
									
								
							|  | @ -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, | ||||
| 		}, | ||||
|  |  | |||
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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" | ||||
| ) | ||||
|  |  | |||
|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| package gtsmodel | ||||
| 
 | ||||
| import "time" | ||||
| 
 | ||||
| // Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance. | ||||
| type Emoji struct { | ||||
| 	ID                     string         `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 | ||||
| } | ||||
|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| 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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										40
									
								
								internal/db/bundb/migrations/util.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								internal/db/bundb/migrations/util.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| 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 | ||||
| } | ||||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										215
									
								
								internal/federation/dereferencing/media.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								internal/federation/dereferencing/media.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| 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, | ||||
| 	) | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -37,6 +37,5 @@ func newHdrBuf(fileSize int) []byte { | |||
| 	if fileSize > 0 && fileSize < bufSize { | ||||
| 		bufSize = fileSize | ||||
| 	} | ||||
| 
 | ||||
| 	return make([]byte, bufSize) | ||||
| } | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
|  | @ -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, | ||||
| 		}, | ||||
| 	) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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()) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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() | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										98
									
								
								internal/processing/common/media.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								internal/processing/common/media.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| 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 | ||||
| } | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
| 	} | ||||
|  |  | |||
|  | @ -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") | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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)) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue