diff options
| author | 2024-06-26 15:01:16 +0000 | |
|---|---|---|
| committer | 2024-06-26 16:01:16 +0100 | |
| commit | 21bb324156f582e918a097ea744e52fc21b2ddf4 (patch) | |
| tree | 50db5cfd42e26224591f59ff62de14a3715677b5 /internal/processing | |
| parent | [docs] restructure federation section (#3038) (diff) | |
| download | gotosocial-21bb324156f582e918a097ea744e52fc21b2ddf4.tar.xz | |
[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
Diffstat (limited to 'internal/processing')
| -rw-r--r-- | internal/processing/account/account_test.go | 2 | ||||
| -rw-r--r-- | internal/processing/account/update.go | 102 | ||||
| -rw-r--r-- | internal/processing/admin/admin.go | 35 | ||||
| -rw-r--r-- | internal/processing/admin/debug_apurl.go | 2 | ||||
| -rw-r--r-- | internal/processing/admin/email.go | 2 | ||||
| -rw-r--r-- | internal/processing/admin/emoji.go | 529 | ||||
| -rw-r--r-- | internal/processing/admin/media.go | 4 | ||||
| -rw-r--r-- | internal/processing/common/common.go | 4 | ||||
| -rw-r--r-- | internal/processing/common/media.go | 98 | ||||
| -rw-r--r-- | internal/processing/instance.go | 20 | ||||
| -rw-r--r-- | internal/processing/media/create.go | 27 | ||||
| -rw-r--r-- | internal/processing/media/getfile.go | 384 | ||||
| -rw-r--r-- | internal/processing/media/media.go | 17 | ||||
| -rw-r--r-- | internal/processing/media/media_test.go | 9 | ||||
| -rw-r--r-- | internal/processing/polls/poll_test.go | 2 | ||||
| -rw-r--r-- | internal/processing/processor.go | 6 | ||||
| -rw-r--r-- | internal/processing/status/status_test.go | 2 | 
17 files changed, 685 insertions, 560 deletions
diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 556f4d91f..8eec1f9dd 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -111,7 +111,7 @@ func (suite *AccountStandardTestSuite) SetupTest() {  	suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails)  	filter := visibility.NewFilter(&suite.state) -	common := common.New(&suite.state, suite.tc, suite.federator, filter) +	common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, filter)  	suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator))  	testrig.StandardDBSetup(suite.db, nil)  	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index ea6abed6e..61e88501f 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -19,10 +19,12 @@ package account  import (  	"context" +	"errors"  	"fmt"  	"io"  	"mime/multipart" +	"codeberg.org/gruf/go-bytesize"  	"github.com/superseriousbusiness/gotosocial/internal/ap"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/config" @@ -203,9 +205,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form  	}  	if form.Avatar != nil && form.Avatar.Size != 0 { -		avatarInfo, err := p.UpdateAvatar(ctx, form.Avatar, nil, account.ID) -		if err != nil { -			return nil, gtserror.NewErrorBadRequest(err) +		avatarInfo, errWithCode := p.UpdateAvatar(ctx, +			account, +			form.Avatar, +			nil, +		) +		if errWithCode != nil { +			return nil, errWithCode  		}  		account.AvatarMediaAttachmentID = avatarInfo.ID  		account.AvatarMediaAttachment = avatarInfo @@ -213,9 +219,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form  	}  	if form.Header != nil && form.Header.Size != 0 { -		headerInfo, err := p.UpdateHeader(ctx, form.Header, nil, account.ID) -		if err != nil { -			return nil, gtserror.NewErrorBadRequest(err) +		headerInfo, errWithCode := p.UpdateHeader(ctx, +			account, +			form.Header, +			nil, +		) +		if errWithCode != nil { +			return nil, errWithCode  		}  		account.HeaderMediaAttachmentID = headerInfo.ID  		account.HeaderMediaAttachment = headerInfo @@ -316,35 +326,33 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form  // for this to become the account's new avatar.  func (p *Processor) UpdateAvatar(  	ctx context.Context, +	account *gtsmodel.Account,  	avatar *multipart.FileHeader,  	description *string, -	accountID string, -) (*gtsmodel.MediaAttachment, error) { -	maxImageSize := config.GetMediaImageMaxSize() -	if avatar.Size > int64(maxImageSize) { -		return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", avatar.Size, maxImageSize) +) ( +	*gtsmodel.MediaAttachment, +	gtserror.WithCode, +) { +	max := config.GetMediaImageMaxSize() +	if sz := bytesize.Size(avatar.Size); sz > max { +		text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)  	} -	data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { +	data := func(_ context.Context) (io.ReadCloser, int64, error) {  		f, err := avatar.Open()  		return f, avatar.Size, err  	} -	// Process the media attachment and load it immediately. -	media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ -		Avatar:      util.Ptr(true), -		Description: description, -	}) - -	attachment, err := media.LoadAttachment(ctx) -	if err != nil { -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) -	} else if attachment.Type == gtsmodel.FileTypeUnknown { -		err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) -	} - -	return attachment, nil +	// Write to instance storage. +	return p.c.StoreLocalMedia(ctx, +		account.ID, +		data, +		media.AdditionalMediaInfo{ +			Avatar:      util.Ptr(true), +			Description: description, +		}, +	)  }  // UpdateHeader does the dirty work of checking the header @@ -353,33 +361,31 @@ func (p *Processor) UpdateAvatar(  // for this to become the account's new header.  func (p *Processor) UpdateHeader(  	ctx context.Context, +	account *gtsmodel.Account,  	header *multipart.FileHeader,  	description *string, -	accountID string, -) (*gtsmodel.MediaAttachment, error) { -	maxImageSize := config.GetMediaImageMaxSize() -	if header.Size > int64(maxImageSize) { -		return nil, gtserror.Newf("size %d exceeded max media size of %d bytes", header.Size, maxImageSize) +) ( +	*gtsmodel.MediaAttachment, +	gtserror.WithCode, +) { +	max := config.GetMediaImageMaxSize() +	if sz := bytesize.Size(header.Size); sz > max { +		text := fmt.Sprintf("size %s exceeds max media size %s", sz, max) +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)  	} -	data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { +	data := func(_ context.Context) (io.ReadCloser, int64, error) {  		f, err := header.Open()  		return f, header.Size, err  	} -	// Process the media attachment and load it immediately. -	media := p.mediaManager.PreProcessMedia(data, accountID, &media.AdditionalMediaInfo{ -		Header:      util.Ptr(true), -		Description: description, -	}) - -	attachment, err := media.LoadAttachment(ctx) -	if err != nil { -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) -	} else if attachment.Type == gtsmodel.FileTypeUnknown { -		err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) -	} - -	return attachment, nil +	// Write to instance storage. +	return p.c.StoreLocalMedia(ctx, +		account.ID, +		data, +		media.AdditionalMediaInfo{ +			Header:      util.Ptr(true), +			Description: description, +		}, +	)  } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 3093b3e36..170298ca5 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -20,20 +20,26 @@ package admin  import (  	"github.com/superseriousbusiness/gotosocial/internal/cleaner"  	"github.com/superseriousbusiness/gotosocial/internal/email" +	"github.com/superseriousbusiness/gotosocial/internal/federation"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/processing/common"  	"github.com/superseriousbusiness/gotosocial/internal/state"  	"github.com/superseriousbusiness/gotosocial/internal/transport"  	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  )  type Processor struct { -	state               *state.State -	cleaner             *cleaner.Cleaner -	converter           *typeutils.Converter -	mediaManager        *media.Manager -	transportController transport.Controller -	emailSender         email.Sender +	// common processor logic +	c *common.Processor + +	state     *state.State +	cleaner   *cleaner.Cleaner +	converter *typeutils.Converter +	federator *federation.Federator +	media     *media.Manager +	transport transport.Controller +	email     email.Sender  	// admin Actions currently  	// undergoing processing @@ -46,21 +52,24 @@ func (p *Processor) Actions() *Actions {  // New returns a new admin processor.  func New( +	common *common.Processor,  	state *state.State,  	cleaner *cleaner.Cleaner, +	federator *federation.Federator,  	converter *typeutils.Converter,  	mediaManager *media.Manager,  	transportController transport.Controller,  	emailSender email.Sender,  ) Processor {  	return Processor{ -		state:               state, -		cleaner:             cleaner, -		converter:           converter, -		mediaManager:        mediaManager, -		transportController: transportController, -		emailSender:         emailSender, - +		c:         common, +		state:     state, +		cleaner:   cleaner, +		converter: converter, +		federator: federator, +		media:     mediaManager, +		transport: transportController, +		email:     emailSender,  		actions: &Actions{  			r:     make(map[string]*gtsmodel.AdminAction),  			state: state, diff --git a/internal/processing/admin/debug_apurl.go b/internal/processing/admin/debug_apurl.go index db3c60d0c..dbf337dc3 100644 --- a/internal/processing/admin/debug_apurl.go +++ b/internal/processing/admin/debug_apurl.go @@ -78,7 +78,7 @@ func (p *Processor) DebugAPUrl(  	}  	// All looks fine. Prepare the transport and (signed) GET request. -	tsport, err := p.transportController.NewTransportForUsername(ctx, adminAcct.Username) +	tsport, err := p.transport.NewTransportForUsername(ctx, adminAcct.Username)  	if err != nil {  		err = gtserror.Newf("error creating transport: %w", err)  		return nil, gtserror.NewErrorInternalError(err, err.Error()) diff --git a/internal/processing/admin/email.go b/internal/processing/admin/email.go index fda60754c..949be6e4b 100644 --- a/internal/processing/admin/email.go +++ b/internal/processing/admin/email.go @@ -55,7 +55,7 @@ func (p *Processor) EmailTest(  		InstanceName:    instance.Title,  	} -	if err := p.emailSender.SendTestEmail(toAddress, testData); err != nil { +	if err := p.email.SendTestEmail(toAddress, testData); err != nil {  		if gtserror.IsSMTP(err) {  			// An error occurred during the SMTP part.  			// We should indicate this to the caller, as diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index dcdf77642..4d1b464d3 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -31,7 +31,6 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/id"  	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/uris"  	"github.com/superseriousbusiness/gotosocial/internal/util"  ) @@ -41,64 +40,21 @@ func (p *Processor) EmojiCreate(  	account *gtsmodel.Account,  	form *apimodel.EmojiCreateRequest,  ) (*apimodel.Emoji, gtserror.WithCode) { -	// Ensure emoji with this shortcode -	// doesn't already exist on the instance. -	maybeExisting, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, form.Shortcode, "") -	if err != nil && !errors.Is(err, db.ErrNoEntries) { -		err := gtserror.Newf("error checking existence of emoji with shortcode %s: %w", form.Shortcode, err) -		return nil, gtserror.NewErrorInternalError(err) -	} - -	if maybeExisting != nil { -		err := fmt.Errorf("emoji with shortcode %s already exists", form.Shortcode) -		return nil, gtserror.NewErrorConflict(err, err.Error()) -	} -	// Prepare data function for emoji processing -	// (just read data from the submitted form). -	data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { +	// Simply read provided form data for emoji data source. +	data := func(_ context.Context) (io.ReadCloser, int64, error) {  		f, err := form.Image.Open()  		return f, form.Image.Size, err  	} -	// If category was supplied on the form, -	// ensure the category exists and provide -	// it as additional info to emoji processing. -	var ai *media.AdditionalEmojiInfo -	if form.CategoryName != "" { -		category, err := p.getOrCreateEmojiCategory(ctx, form.CategoryName) -		if err != nil { -			return nil, gtserror.NewErrorInternalError(err) -		} - -		ai = &media.AdditionalEmojiInfo{ -			CategoryID: &category.ID, -		} -	} - -	// Generate new emoji ID and URI. -	emojiID, err := id.NewRandomULID() -	if err != nil { -		err := gtserror.Newf("error creating id for new emoji: %w", err) -		return nil, gtserror.NewErrorInternalError(err) -	} - -	emojiURI := uris.URIForEmoji(emojiID) - -	// Begin media processing. -	processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, -		data, form.Shortcode, emojiID, emojiURI, ai, false, +	// Attempt to create the new local emoji. +	emoji, errWithCode := p.createEmoji(ctx, +		form.Shortcode, +		form.CategoryName, +		data,  	) -	if err != nil { -		err := gtserror.Newf("error processing emoji: %w", err) -		return nil, gtserror.NewErrorInternalError(err) -	} - -	// Complete processing immediately. -	emoji, err := processingEmoji.LoadEmoji(ctx) -	if err != nil { -		err := gtserror.Newf("error loading emoji: %w", err) -		return nil, gtserror.NewErrorInternalError(err) +	if errWithCode != nil { +		return nil, errWithCode  	}  	apiEmoji, err := p.converter.EmojiToAPIEmoji(ctx, emoji) @@ -110,53 +66,6 @@ func (p *Processor) EmojiCreate(  	return &apiEmoji, nil  } -// emojisGetFilterParams builds extra -// query parameters to return as part -// of an Emojis pageable response. -// -// The returned string will look like: -// -// "filter=domain:all,enabled,shortcode:example" -func emojisGetFilterParams( -	shortcode string, -	domain string, -	includeDisabled bool, -	includeEnabled bool, -) string { -	var filterBuilder strings.Builder -	filterBuilder.WriteString("filter=") - -	switch domain { -	case "", "local": -		// Local emojis only. -		filterBuilder.WriteString("domain:local") - -	case db.EmojiAllDomains: -		// Local or remote. -		filterBuilder.WriteString("domain:all") - -	default: -		// Specific domain only. -		filterBuilder.WriteString("domain:" + domain) -	} - -	if includeDisabled != includeEnabled { -		if includeDisabled { -			filterBuilder.WriteString(",disabled") -		} -		if includeEnabled { -			filterBuilder.WriteString(",enabled") -		} -	} - -	if shortcode != "" { -		// Specific shortcode only. -		filterBuilder.WriteString(",shortcode:" + shortcode) -	} - -	return filterBuilder.String() -} -  // EmojisGet returns an admin view of custom  // emojis, filtered with the given parameters.  func (p *Processor) EmojisGet( @@ -287,21 +196,24 @@ func (p *Processor) EmojiDelete(  // given id, using the provided form parameters.  func (p *Processor) EmojiUpdate(  	ctx context.Context, -	id string, +	emojiID string,  	form *apimodel.EmojiUpdateRequest,  ) (*apimodel.AdminEmoji, gtserror.WithCode) { -	emoji, err := p.state.DB.GetEmojiByID(ctx, id) + +	// Get the emoji with given ID from the database. +	emoji, err := p.state.DB.GetEmojiByID(ctx, emojiID)  	if err != nil && !errors.Is(err, db.ErrNoEntries) { -		err := gtserror.Newf("db error: %w", err) +		err := gtserror.Newf("error fetching emoji from db: %w", err)  		return nil, gtserror.NewErrorInternalError(err)  	} +	// Check found.  	if emoji == nil { -		err := gtserror.Newf("no emoji with id %s found in the db", id) -		return nil, gtserror.NewErrorNotFound(err) +		const text = "emoji not found" +		return nil, gtserror.NewErrorNotFound(errors.New(text), text)  	} -	switch t := form.Type; t { +	switch form.Type {  	case apimodel.EmojiUpdateCopy:  		return p.emojiUpdateCopy(ctx, emoji, form.Shortcode, form.CategoryName) @@ -313,8 +225,8 @@ func (p *Processor) EmojiUpdate(  		return p.emojiUpdateModify(ctx, emoji, form.Image, form.CategoryName)  	default: -		err := fmt.Errorf("unrecognized emoji action type %s", t) -		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +		const text = "unrecognized emoji update action type" +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)  	}  } @@ -342,56 +254,6 @@ func (p *Processor) EmojiCategoriesGet(  	return apiCategories, nil  } -/* -	UTIL FUNCTIONS -*/ - -// getOrCreateEmojiCategory either gets an existing -// category with the given name from the database, -// or, if the category doesn't yet exist, it creates -// the category and then returns it. -func (p *Processor) getOrCreateEmojiCategory( -	ctx context.Context, -	name string, -) (*gtsmodel.EmojiCategory, error) { -	category, err := p.state.DB.GetEmojiCategoryByName(ctx, name) -	if err != nil && !errors.Is(err, db.ErrNoEntries) { -		return nil, gtserror.Newf( -			"database error trying get emoji category %s: %w", -			name, err, -		) -	} - -	if category != nil { -		// We had it already. -		return category, nil -	} - -	// We don't have the category yet, -	// create it with the given name. -	categoryID, err := id.NewRandomULID() -	if err != nil { -		return nil, gtserror.Newf( -			"error generating id for new emoji category %s: %w", -			name, err, -		) -	} - -	category = >smodel.EmojiCategory{ -		ID:   categoryID, -		Name: name, -	} - -	if err := p.state.DB.PutEmojiCategory(ctx, category); err != nil { -		return nil, gtserror.Newf( -			"db error putting new emoji category %s: %w", -			name, err, -		) -	} - -	return category, nil -} -  // emojiUpdateCopy copies and stores the given  // *remote* emoji as a *local* emoji, preserving  // the same image, and using the provided shortcode. @@ -400,99 +262,56 @@ func (p *Processor) getOrCreateEmojiCategory(  // emoji already stored in the database + storage.  func (p *Processor) emojiUpdateCopy(  	ctx context.Context, -	targetEmoji *gtsmodel.Emoji, +	target *gtsmodel.Emoji,  	shortcode *string, -	category *string, +	categoryName *string,  ) (*apimodel.AdminEmoji, gtserror.WithCode) { -	if targetEmoji.IsLocal() { -		err := fmt.Errorf("emoji %s is not a remote emoji, cannot copy it to local", targetEmoji.ID) -		return nil, gtserror.NewErrorBadRequest(err, err.Error()) -	} - -	if shortcode == nil { -		err := errors.New("no shortcode provided") -		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)  	} -	sc := *shortcode -	if sc == "" { -		err := errors.New("empty shortcode provided") -		return nil, gtserror.NewErrorBadRequest(err, err.Error()) -	} - -	// 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) -	} +	// Ensure target emoji is locally cached. +	target, err := p.federator.RefreshEmoji( +		ctx, +		target, -	if maybeExisting != nil { -		err := fmt.Errorf("emoji with shortcode %s already exists on this instance", sc) -		return nil, gtserror.NewErrorConflict(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)  	} -	// 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 -	} - -	// 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, -		} +		rc, err := p.state.Storage.GetStream(ctx, target.ImagePath) +		return rc, int64(target.ImageFileSize), err  	} -	// 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 err != nil { -		err := gtserror.Newf("error processing 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) +	if errWithCode != nil { +		return nil, errWithCode  	} -	adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, newEmoji) +	apiEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)  	if err != nil { -		err := gtserror.Newf("error converting emoji %s to admin emoji: %w", newEmoji.ID, err) +		err := gtserror.Newf("error converting emoji: %w", 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, -			} -		} - -		// Begin media processing. -		processingEmoji, err := p.mediaManager.PreProcessEmoji(ctx, -			data, emoji.Shortcode, emoji.ID, emoji.URI, ai, false, -		) -		if err != nil { -			err := gtserror.Newf("error processing emoji: %w", err) -			return nil, gtserror.NewErrorInternalError(err) -		} +		// Prepare emoji model for recache from new data. +		processing := p.media.RecacheEmoji(emoji, data) -		// Replace emoji ptr with newly-processed version. -		emoji, err = processingEmoji.LoadEmoji(ctx) +		// Load to trigger update + write. +		emoji, err = processing.Load(ctx)  		if err != nil { -			err := gtserror.Newf("error loading emoji: %w", err) +			err := gtserror.Newf("error processing emoji %s: %w", emoji.Shortcode, err)  			return nil, gtserror.NewErrorInternalError(err)  		}  	}  	adminEmoji, err := p.converter.EmojiToAdminAPIEmoji(ctx, emoji)  	if err != nil { -		err := gtserror.Newf("error converting emoji %s to admin emoji: %w", emoji.ID, err) +		err := gtserror.Newf("error converting emoji: %w", err)  		return nil, gtserror.NewErrorInternalError(err)  	}  	return adminEmoji, nil  } + +// createEmoji will create a new local emoji +// with the given shortcode, attached category +// name (if any) and data source function. +func (p *Processor) createEmoji( +	ctx context.Context, +	shortcode string, +	categoryName string, +	data media.DataFunc, +) ( +	*gtsmodel.Emoji, +	gtserror.WithCode, +) { +	// Validate shortcode. +	if shortcode == "" { +		const text = "empty shortcode name" +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	// Look for an existing local emoji with shortcode to ensure this is new. +	existing, err := p.state.DB.GetEmojiByShortcodeDomain(ctx, shortcode, "") +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err := gtserror.Newf("error fetching emoji from db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} else if existing != nil { +		const text = "emoji with shortcode already exists" +		return nil, gtserror.NewErrorConflict(errors.New(text), text) +	} + +	var categoryID *string + +	if categoryName != "" { +		// A category was provided, get / create relevant emoji category. +		category, errWithCode := p.mustGetEmojiCategory(ctx, categoryName) +		if errWithCode != nil { +			return nil, errWithCode +		} + +		// Set category ID for emoji. +		categoryID = &category.ID +	} + +	// Store to instance storage. +	return p.c.StoreLocalEmoji( +		ctx, +		shortcode, +		data, +		media.AdditionalEmojiInfo{ +			CategoryID: categoryID, +		}, +	) +} + +// mustGetEmojiCategory either gets an existing +// category with the given name from the database, +// or, if the category doesn't yet exist, it creates +// the category and then returns it. +func (p *Processor) mustGetEmojiCategory( +	ctx context.Context, +	name string, +) ( +	*gtsmodel.EmojiCategory, +	gtserror.WithCode, +) { +	// Look for an existing emoji category with name. +	category, err := p.state.DB.GetEmojiCategoryByName(ctx, name) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err := gtserror.Newf("error fetching emoji category from db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	if category != nil { +		// We had it already. +		return category, nil +	} + +	// Create new ID. +	id := id.NewULID() + +	// Prepare new category for insertion. +	category = >smodel.EmojiCategory{ +		ID:   id, +		Name: name, +	} + +	// Insert new category into the database. +	err = p.state.DB.PutEmojiCategory(ctx, category) +	if err != nil { +		err := gtserror.Newf("error inserting emoji category into db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	return category, nil +} + +// emojisGetFilterParams builds extra +// query parameters to return as part +// of an Emojis pageable response. +// +// The returned string will look like: +// +// "filter=domain:all,enabled,shortcode:example" +func emojisGetFilterParams( +	shortcode string, +	domain string, +	includeDisabled bool, +	includeEnabled bool, +) string { +	var filterBuilder strings.Builder +	filterBuilder.WriteString("filter=") + +	switch domain { +	case "", "local": +		// Local emojis only. +		filterBuilder.WriteString("domain:local") + +	case db.EmojiAllDomains: +		// Local or remote. +		filterBuilder.WriteString("domain:all") + +	default: +		// Specific domain only. +		filterBuilder.WriteString("domain:" + domain) +	} + +	if includeDisabled != includeEnabled { +		if includeDisabled { +			filterBuilder.WriteString(",disabled") +		} +		if includeEnabled { +			filterBuilder.WriteString(",enabled") +		} +	} + +	if shortcode != "" { +		// Specific shortcode only. +		filterBuilder.WriteString(",shortcode:" + shortcode) +	} + +	return filterBuilder.String() +} diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go index 13dcb7d28..edbcbe349 100644 --- a/internal/processing/admin/media.go +++ b/internal/processing/admin/media.go @@ -28,7 +28,7 @@ import (  // MediaRefetch forces a refetch of remote emojis.  func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode { -	transport, err := p.transportController.NewTransportForUsername(ctx, requestingAccount.Username) +	transport, err := p.transport.NewTransportForUsername(ctx, requestingAccount.Username)  	if err != nil {  		err = fmt.Errorf("error getting transport for user %s during media refetch request: %w", requestingAccount.Username, err)  		return gtserror.NewErrorInternalError(err) @@ -36,7 +36,7 @@ func (p *Processor) MediaRefetch(ctx context.Context, requestingAccount *gtsmode  	go func() {  		log.Info(ctx, "starting emoji refetch") -		refetched, err := p.mediaManager.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia) +		refetched, err := p.media.RefetchEmojis(context.Background(), domain, transport.DereferenceMedia)  		if err != nil {  			log.Errorf(ctx, "error refetching emojis: %s", err)  		} else { diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index e4a49cc45..942cecc59 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -20,6 +20,7 @@ package common  import (  	"github.com/superseriousbusiness/gotosocial/internal/federation"  	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" +	"github.com/superseriousbusiness/gotosocial/internal/media"  	"github.com/superseriousbusiness/gotosocial/internal/state"  	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  ) @@ -29,6 +30,7 @@ import (  // processing subsection of the codebase.  type Processor struct {  	state     *state.State +	media     *media.Manager  	converter *typeutils.Converter  	federator *federation.Federator  	filter    *visibility.Filter @@ -37,12 +39,14 @@ type Processor struct {  // New returns a new Processor instance.  func New(  	state *state.State, +	media *media.Manager,  	converter *typeutils.Converter,  	federator *federation.Federator,  	filter *visibility.Filter,  ) Processor {  	return Processor{  		state:     state, +		media:     media,  		converter: converter,  		federator: federator,  		filter:    filter, diff --git a/internal/processing/common/media.go b/internal/processing/common/media.go new file mode 100644 index 000000000..7baf30345 --- /dev/null +++ b/internal/processing/common/media.go @@ -0,0 +1,98 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <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 +} diff --git a/internal/processing/instance.go b/internal/processing/instance.go index a93936425..a9be6db1d 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -246,9 +246,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe  	if form.Avatar != nil && form.Avatar.Size != 0 {  		// Process instance avatar image + description. -		avatarInfo, err := p.account.UpdateAvatar(ctx, form.Avatar, form.AvatarDescription, instanceAcc.ID) -		if err != nil { -			return nil, gtserror.NewErrorBadRequest(err, "error processing avatar") +		avatarInfo, errWithCode := p.account.UpdateAvatar(ctx, +			instanceAcc, +			form.Avatar, +			form.AvatarDescription, +		) +		if errWithCode != nil { +			return nil, errWithCode  		}  		instanceAcc.AvatarMediaAttachmentID = avatarInfo.ID  		instanceAcc.AvatarMediaAttachment = avatarInfo @@ -264,9 +268,13 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe  	if form.Header != nil && form.Header.Size != 0 {  		// process instance header image -		headerInfo, err := p.account.UpdateHeader(ctx, form.Header, nil, instanceAcc.ID) -		if err != nil { -			return nil, gtserror.NewErrorBadRequest(err, "error processing header") +		headerInfo, errWithCode := p.account.UpdateHeader(ctx, +			instanceAcc, +			form.Header, +			nil, +		) +		if errWithCode != nil { +			return nil, errWithCode  		}  		instanceAcc.HeaderMediaAttachmentID = headerInfo.ID  		instanceAcc.HeaderMediaAttachment = headerInfo diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index fe20457b4..0dbe997de 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -30,7 +30,7 @@ import (  // Create creates a new media attachment belonging to the given account, using the request form.  func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) { -	data := func(innerCtx context.Context) (io.ReadCloser, int64, error) { +	data := func(_ context.Context) (io.ReadCloser, int64, error) {  		f, err := form.File.Open()  		return f, form.File.Size, err  	} @@ -41,19 +41,18 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form  		return nil, gtserror.NewErrorBadRequest(err, err.Error())  	} -	// process the media attachment and load it immediately -	media := p.mediaManager.PreProcessMedia(data, account.ID, &media.AdditionalMediaInfo{ -		Description: &form.Description, -		FocusX:      &focusX, -		FocusY:      &focusY, -	}) - -	attachment, err := media.LoadAttachment(ctx) -	if err != nil { -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) -	} else if attachment.Type == gtsmodel.FileTypeUnknown { -		err = gtserror.Newf("could not process uploaded file with extension %s", attachment.File.ContentType) -		return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) +	// Create local media and write to instance storage. +	attachment, errWithCode := p.c.StoreLocalMedia(ctx, +		account.ID, +		data, +		media.AdditionalMediaInfo{ +			Description: &form.Description, +			FocusX:      &focusX, +			FocusY:      &focusY, +		}, +	) +	if errWithCode != nil { +		return nil, errWithCode  	}  	apiAttachment, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 28f5e6464..7ba549029 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -19,14 +19,14 @@ package media  import (  	"context" +	"errors"  	"fmt" -	"io"  	"net/url"  	"strings"  	"time"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" -	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/media" @@ -38,7 +38,7 @@ import (  // to the caller via an io.reader embedded in *apimodel.Content.  func (p *Processor) GetFile(  	ctx context.Context, -	requestingAccount *gtsmodel.Account, +	requester *gtsmodel.Account,  	form *apimodel.GetContentRequestForm,  ) (*apimodel.Content, gtserror.WithCode) {  	// parse the form fields @@ -69,13 +69,13 @@ func (p *Processor) GetFile(  	}  	// make sure the requesting account and the media account don't block each other -	if requestingAccount != nil { -		blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, owningAccountID) +	if requester != nil { +		blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, owningAccountID)  		if err != nil { -			return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requestingAccount.ID, err)) +			return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requester.ID, err))  		}  		if blocked { -			return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requestingAccount.ID)) +			return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requester.ID))  		}  	} @@ -83,71 +83,78 @@ 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 parseType(s string) (media.Type, error) { -	switch s { -	case string(media.TypeAttachment): -		return media.TypeAttachment, nil -	case string(media.TypeHeader): -		return media.TypeHeader, nil -	case string(media.TypeAvatar): -		return media.TypeAvatar, nil -	case string(media.TypeEmoji): -		return media.TypeEmoji, nil +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)  	} -	return "", fmt.Errorf("%s not a recognized media.Type", s) -} -func parseSize(s string) (media.Size, error) { -	switch s { -	case string(media.SizeSmall): -		return media.SizeSmall, nil -	case string(media.SizeOriginal): -		return media.SizeOriginal, nil -	case string(media.SizeStatic): -		return media.SizeStatic, nil +	if attach == nil { +		const text = "media not found" +		return nil, gtserror.NewErrorNotFound(errors.New(text), text)  	} -	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) +	// 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! */)  	} -	if a.AccountID != owningAccountID { -		err = gtserror.Newf("attachment %s is not owned by %s", wantedMediaID, owningAccountID) -		return nil, gtserror.NewErrorNotFound(err) -	} +	var remoteURL *url.URL +	if attach.RemoteURL != "" { -	// 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) +		// Parse media remote URL to valid URL object. +		remoteURL, err = url.Parse(attach.RemoteURL)  		if err != nil { -			err = gtserror.Newf("error parsing remote URL of 'Unknown'-type attachment for redirection: %w", err) +			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), @@ -156,162 +163,197 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount  		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 -		} +	var requestUser string -		// 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) -		} +	if requester != nil { +		// Set requesting acc username. +		requestUser = requester.Username +	} -		// 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)) -		} +	// 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) +	} -		// 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)) -		} +	// Start preparing API content model. +	apiContent := &apimodel.Content{ +		ContentUpdated: attach.UpdatedAt,  	} -	var ( -		storagePath       string -		attachmentContent = &apimodel.Content{ -			ContentUpdated: a.UpdatedAt, -		} -	) +	// Retrieve appropriate +	// size file from storage. +	switch sizeStr { -	// 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 +		apiContent.ContentType = attach.File.ContentType +		apiContent.ContentLength = int64(attach.File.FileSize) +		return p.getContent(ctx, +			attach.File.Path, +			apiContent, +		) +  	case media.SizeSmall: -		attachmentContent.ContentType = a.Thumbnail.ContentType -		attachmentContent.ContentLength = int64(a.Thumbnail.FileSize) -		storagePath = a.Thumbnail.Path +		apiContent.ContentType = attach.Thumbnail.ContentType +		apiContent.ContentLength = int64(attach.Thumbnail.FileSize) +		return p.getContent(ctx, +			attach.Thumbnail.Path, +			apiContent, +		) +  	default: -		return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) +		const text = "invalid media attachment size" +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)  	} - -	// ... 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 +func (p *Processor) getEmojiContent( +	ctx context.Context, -	// 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, +	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), -		fileName, +		emojiID,  		"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)) +	// 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 *e.Disabled { -		return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", fileName)) +	if emoji == nil { +		const text = "emoji not found" +		return nil, gtserror.NewErrorNotFound(errors.New(text), text)  	} -	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)) -		} +	if *emoji.Disabled { +		const text = "emoji has been disabled" +		return nil, gtserror.NewErrorNotFound(errors.New(text), text) +	} -		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) -		} +	// 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 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)) -		} +	// Start preparing API content model. +	apiContent := &apimodel.Content{} -		// 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)) -		} -	} +	// Retrieve appropriate +	// size file from storage. +	switch sizeStr { -	switch emojiSize {  	case media.SizeOriginal: -		emojiContent.ContentType = e.ImageContentType -		emojiContent.ContentLength = int64(e.ImageFileSize) -		storagePath = e.ImagePath +		apiContent.ContentType = emoji.ImageContentType +		apiContent.ContentLength = int64(emoji.ImageFileSize) +		return p.getContent(ctx, +			emoji.ImagePath, +			apiContent, +		) +  	case media.SizeStatic: -		emojiContent.ContentType = e.ImageStaticContentType -		emojiContent.ContentLength = int64(e.ImageStaticFileSize) -		storagePath = e.ImageStaticPath +		apiContent.ContentType = emoji.ImageStaticContentType +		apiContent.ContentLength = int64(emoji.ImageStaticFileSize) +		return p.getContent(ctx, +			emoji.ImageStaticPath, +			apiContent, +		) +  	default: -		return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", emojiSize)) +		const text = "invalid media attachment size" +		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)  	} - -	return p.retrieveFromStorage(ctx, storagePath, emojiContent)  } -func (p *Processor) retrieveFromStorage(ctx context.Context, storagePath string, content *apimodel.Content) (*apimodel.Content, gtserror.WithCode) { +// 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 a pre-signed URL instead of serving the content. -	if url := p.state.Storage.URL(ctx, storagePath); url != nil { +	// 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  	} -	reader, err := p.state.Storage.GetStream(ctx, storagePath) -	if err != nil { -		return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) +	// 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)  	} -	content.Content = reader +	// 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 { +	case string(media.TypeAttachment): +		return media.TypeAttachment, nil +	case string(media.TypeHeader): +		return media.TypeHeader, nil +	case string(media.TypeAvatar): +		return media.TypeAvatar, nil +	case string(media.TypeEmoji): +		return media.TypeEmoji, nil +	} +	return "", fmt.Errorf("%s not a recognized media.Type", s) +} + +func parseSize(s string) (media.Size, error) { +	switch s { +	case string(media.SizeSmall): +		return media.SizeSmall, nil +	case string(media.SizeOriginal): +		return media.SizeOriginal, nil +	case string(media.SizeStatic): +		return media.SizeStatic, nil +	} +	return "", fmt.Errorf("%s not a recognized media.Size", s) +} diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go index 22c455920..76ed68f5a 100644 --- a/internal/processing/media/media.go +++ b/internal/processing/media/media.go @@ -18,24 +18,39 @@  package media  import ( +	"github.com/superseriousbusiness/gotosocial/internal/federation"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/processing/common"  	"github.com/superseriousbusiness/gotosocial/internal/state"  	"github.com/superseriousbusiness/gotosocial/internal/transport"  	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  )  type Processor struct { +	// common processor logic +	c *common.Processor +  	state               *state.State  	converter           *typeutils.Converter +	federator           *federation.Federator  	mediaManager        *media.Manager  	transportController transport.Controller  }  // New returns a new media processor. -func New(state *state.State, converter *typeutils.Converter, mediaManager *media.Manager, transportController transport.Controller) Processor { +func New( +	common *common.Processor, +	state *state.State, +	converter *typeutils.Converter, +	federator *federation.Federator, +	mediaManager *media.Manager, +	transportController transport.Controller, +) Processor {  	return Processor{ +		c:                   common,  		state:               state,  		converter:           converter, +		federator:           federator,  		mediaManager:        mediaManager,  		transportController: transportController,  	} diff --git a/internal/processing/media/media_test.go b/internal/processing/media/media_test.go index 523428140..80f1a7be7 100644 --- a/internal/processing/media/media_test.go +++ b/internal/processing/media/media_test.go @@ -20,8 +20,10 @@ package media_test  import (  	"github.com/stretchr/testify/suite"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/processing/common"  	mediaprocessing "github.com/superseriousbusiness/gotosocial/internal/processing/media"  	"github.com/superseriousbusiness/gotosocial/internal/state"  	"github.com/superseriousbusiness/gotosocial/internal/storage" @@ -78,7 +80,12 @@ func (suite *MediaStandardTestSuite) SetupTest() {  	suite.state.Storage = suite.storage  	suite.mediaManager = testrig.NewTestMediaManager(&suite.state)  	suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) -	suite.mediaProcessor = mediaprocessing.New(&suite.state, suite.tc, suite.mediaManager, suite.transportController) + +	federator := testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) +	filter := visibility.NewFilter(&suite.state) +	common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, filter) + +	suite.mediaProcessor = mediaprocessing.New(&common, &suite.state, suite.tc, federator, suite.mediaManager, suite.transportController)  	testrig.StandardDBSetup(suite.db, nil)  	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")  } diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index 847612503..bf6ae4aad 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -57,7 +57,7 @@ func (suite *PollTestSuite) SetupTest() {  	mediaMgr := media.NewManager(&suite.state)  	federator := testrig.NewTestFederator(&suite.state, controller, mediaMgr)  	suite.filter = visibility.NewFilter(&suite.state) -	common := common.New(&suite.state, converter, federator, suite.filter) +	common := common.New(&suite.state, mediaMgr, converter, federator, suite.filter)  	suite.polls = polls.New(&common, &suite.state, converter)  } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8765819d3..fb6b05d80 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -179,15 +179,15 @@ func NewProcessor(  	//  	// Start with sub processors that will  	// be required by the workers processor. -	common := common.New(state, converter, federator, filter) +	common := common.New(state, mediaManager, converter, federator, filter)  	processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) -	processor.media = media.New(state, converter, mediaManager, federator.TransportController()) +	processor.media = media.New(&common, state, converter, federator, mediaManager, federator.TransportController())  	processor.stream = stream.New(state, oauthServer)  	// Instantiate the rest of the sub  	// processors + pin them to this struct.  	processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) -	processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) +	processor.admin = admin.New(&common, state, cleaner, federator, converter, mediaManager, federator.TransportController(), emailSender)  	processor.fedi = fedi.New(state, &common, converter, federator, filter)  	processor.filtersv1 = filtersv1.New(state, converter, &processor.stream)  	processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 171e4b488..9eba78ec6 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -96,7 +96,7 @@ func (suite *StatusStandardTestSuite) SetupTest() {  		suite.typeConverter,  	) -	common := common.New(&suite.state, suite.typeConverter, suite.federator, filter) +	common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, filter)  	polls := polls.New(&common, &suite.state, suite.typeConverter)  	suite.status = status.New(&suite.state, &common, &polls, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(&suite.state, suite.federator))  | 
