diff --git a/internal/db/account.go b/internal/db/account.go index dec36d2ac..4f02a4d29 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -57,6 +57,9 @@ type Account interface { // GetAccountByFollowersURI returns one account with the given followers_uri, or an error if something goes wrong. GetAccountByFollowersURI(ctx context.Context, uri string) (*gtsmodel.Account, error) + // GetAccountByMovedToURI returns any accounts with given moved_to_uri set. + GetAccountsByMovedToURI(ctx context.Context, uri string) ([]*gtsmodel.Account, error) + // GetAccounts returns accounts // with the given parameters. GetAccounts( diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 4e969e0ef..eb5385c70 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -252,6 +252,27 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts return a.GetAccountByUsernameDomain(ctx, username, domain) } +func (a *accountDB) GetAccountsByMovedToURI(ctx context.Context, uri string) ([]*gtsmodel.Account, error) { + var accountIDs []string + + // Find all account IDs with + // given moved_to_uri column. + if err := a.db.NewSelect(). + Table("accounts"). + Column("id"). + Where("? = ?", bun.Ident("moved_to_uri"), uri). + Scan(ctx, &accountIDs); err != nil { + return nil, err + } + + if len(accountIDs) == 0 { + return nil, nil + } + + // Return account models for all found IDs. + return a.GetAccountsByIDs(ctx, accountIDs) +} + // GetAccounts selects accounts using the given parameters. // Unlike with other functions, the paging for GetAccounts // is done not by ID, but by a concatenation of `[domain]/@[username]`, diff --git a/internal/federation/dereferencing/announce.go b/internal/federation/dereferencing/announce.go index 02b1d5e5c..6516bdced 100644 --- a/internal/federation/dereferencing/announce.go +++ b/internal/federation/dereferencing/announce.go @@ -22,7 +22,6 @@ import ( "errors" "net/url" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -56,25 +55,17 @@ func (d *Dereferencer) EnrichAnnounce( ) } - // Fetch/deref status being boosted. - var target *gtsmodel.Status - - if targetURIObj.Host == config.GetHost() { - // This is a local status, fetch from the database - target, err = d.state.DB.GetStatusByURI(ctx, targetURI) - } else { - // This is a remote status, we need to dereference it. - // - // d.GetStatusByURI will handle domain block checking for us, - // so we don't try to deref an announce target on a blocked host. - target, _, err = d.GetStatusByURI(ctx, requestUser, targetURIObj) + // Fetch and dereference status being boosted, noting that + // d.GetStatusByURI handles domain blocks and local statuses. + target, _, err := d.GetStatusByURI(ctx, requestUser, targetURIObj) + if err != nil { + return nil, gtserror.Newf("error fetching boost target %s: %w", targetURI, err) } - if err != nil { - return nil, gtserror.Newf( - "error getting boost target status %s: %w", - targetURI, err, - ) + if target.BoostOfID != "" { + // Ensure that the target is not a boost (should not be possible). + err := gtserror.Newf("target status %s is a boost", targetURI) + return nil, err } // Generate an ID for the boost wrapper status. diff --git a/internal/processing/account/move.go b/internal/processing/account/move.go index 21e4f887b..44f8da268 100644 --- a/internal/processing/account/move.go +++ b/internal/processing/account/move.go @@ -27,7 +27,9 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" @@ -44,8 +46,8 @@ func (p *Processor) MoveSelf( ) gtserror.WithCode { // Ensure valid MovedToURI. if form.MovedToURI == "" { - err := errors.New("no moved_to_uri provided in account Move request") - return gtserror.NewErrorBadRequest(err, err.Error()) + const text = "no moved_to_uri provided in Move request" + return gtserror.NewErrorBadRequest(errors.New(text), text) } targetAcctURIStr := form.MovedToURI @@ -56,29 +58,30 @@ func (p *Processor) MoveSelf( } if targetAcctURI.Scheme != "https" && targetAcctURI.Scheme != "http" { - err := errors.New("invalid moved_to_uri provided in account Move request: uri scheme must be http or https") - return gtserror.NewErrorBadRequest(err, err.Error()) + const text = "invalid move_to_uri in Move request: scheme must be http(s)" + return gtserror.NewErrorBadRequest(errors.New(text), text) } - // Self account Move requires password to ensure it's for real. + // Self account Move requires + // password to ensure it's for real. if form.Password == "" { - err := errors.New("no password provided in account Move request") - return gtserror.NewErrorBadRequest(err, err.Error()) + const text = "no password provided in Move request" + return gtserror.NewErrorBadRequest(errors.New(text), text) } if err := bcrypt.CompareHashAndPassword( []byte(authed.User.EncryptedPassword), []byte(form.Password), ); err != nil { - err := errors.New("invalid password provided in account Move request") - return gtserror.NewErrorBadRequest(err, err.Error()) + const text = "invalid password provided in Move request" + return gtserror.NewErrorBadRequest(errors.New(text), text) } // We can't/won't validate Move activities // to domains we have blocked, so check this. targetDomainBlocked, err := p.state.DB.IsDomainBlocked(ctx, targetAcctURI.Host) if err != nil { - err := fmt.Errorf( + err := gtserror.Newf( "db error checking if target domain %s blocked: %w", targetAcctURI.Host, err, ) @@ -86,12 +89,12 @@ func (p *Processor) MoveSelf( } if targetDomainBlocked { - err := fmt.Errorf( + text := fmt.Sprintf( "domain of %s is blocked from this instance; "+ "you will not be able to Move to that account", targetAcctURIStr, ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } var ( @@ -123,22 +126,24 @@ func (p *Processor) MoveSelf( targetAcctURI, ) if err != nil { - err := fmt.Errorf("error dereferencing moved_to_uri account: %w", err) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + const text = "error dereferencing moved_to_uri" + err := gtserror.Newf("error dereferencing move_to_uri: %w", err) + return gtserror.NewErrorUnprocessableEntity(err, text) } if !targetAcct.SuspendedAt.IsZero() { - err := fmt.Errorf( + text := fmt.Sprintf( "target account %s is suspended from this instance; "+ "you will not be able to Move to that account", targetAcct.URI, ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } - if targetAcct.IsRemote() { - // Force refresh Move target account - // to ensure we have up-to-date version. + if targetAcctable == nil { + // Target account was not dereferenced, now + // force refresh Move target account to ensure we + // have most up-to-date version (non remote = no-op). targetAcct, _, err = p.federator.RefreshAccount(ctx, originAcct.Username, targetAcct, @@ -146,11 +151,9 @@ func (p *Processor) MoveSelf( dereferencing.Freshest, ) if err != nil { - err := fmt.Errorf( - "error refreshing target account %s: %w", - targetAcctURIStr, err, - ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + const text = "error dereferencing moved_to_uri" + err := gtserror.Newf("error dereferencing move_to_uri: %w", err) + return gtserror.NewErrorUnprocessableEntity(err, text) } } @@ -158,33 +161,41 @@ func (p *Processor) MoveSelf( // this move reattempt is to the same account. if originAcct.IsMoving() && originAcct.MovedToURI != targetAcct.URI { - err := fmt.Errorf( + text := fmt.Sprintf( "your account is already Moving or has Moved to %s; you cannot also Move to %s", originAcct.MovedToURI, targetAcct.URI, ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } // Target account MUST be aliased to this // account for this to be a valid Move. if !slices.Contains(targetAcct.AlsoKnownAsURIs, originAcct.URI) { - err := fmt.Errorf( + text := fmt.Sprintf( "target account %s is not aliased to this account via alsoKnownAs; "+ "if you just changed it, please wait a few minutes and try the Move again", targetAcct.URI, ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } // Target account cannot itself have // already Moved somewhere else. if targetAcct.MovedToURI != "" { - err := fmt.Errorf( + text := fmt.Sprintf( "target account %s has already Moved somewhere else (%s); "+ "you will not be able to Move to that account", targetAcct.URI, targetAcct.MovedToURI, ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Check this isn't a recursive loop of moves. + if errWithCode := p.checkMoveRecursion(ctx, + originAcct, + targetAcct, + ); errWithCode != nil { + return errWithCode } // If a Move has been *attempted* within last 5m, @@ -194,7 +205,7 @@ func (p *Processor) MoveSelf( ctx, originAcct.URI, targetAcct.URI, ) if err != nil { - err := fmt.Errorf( + err := gtserror.Newf( "error checking latest Move attempt involving origin %s and target %s: %w", originAcct.URI, targetAcct.URI, err, ) @@ -203,12 +214,12 @@ func (p *Processor) MoveSelf( if !latestMoveAttempt.IsZero() && time.Since(latestMoveAttempt) < 5*time.Minute { - err := fmt.Errorf( + text := fmt.Sprintf( "your account or target account have been involved in a Move attempt within "+ "the last 5 minutes, will not process Move; please try again after %s", latestMoveAttempt.Add(5*time.Minute), ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } // If a Move has *succeeded* within the last week @@ -218,7 +229,7 @@ func (p *Processor) MoveSelf( ctx, originAcct.URI, targetAcct.URI, ) if err != nil { - err := fmt.Errorf( + err := gtserror.Newf( "error checking latest Move success involving origin %s and target %s: %w", originAcct.URI, targetAcct.URI, err, ) @@ -227,12 +238,12 @@ func (p *Processor) MoveSelf( if !latestMoveSuccess.IsZero() && time.Since(latestMoveSuccess) < 168*time.Hour { - err := fmt.Errorf( + text := fmt.Sprintf( "your account or target account have been involved in a successful Move within "+ "the last 7 days, will not process Move; please try again after %s", latestMoveSuccess.Add(168*time.Hour), ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } // See if we have a Move stored already @@ -246,21 +257,21 @@ func (p *Processor) MoveSelf( move = originAcct.Move if move == nil { // This shouldn't happen... - err := fmt.Errorf("nil move for id %s", originAcct.MoveID) + err := gtserror.Newf("error fetching move %s (was nil)", originAcct.MovedToURI) return gtserror.NewErrorInternalError(err) } if move.OriginURI != originAcct.URI || move.TargetURI != targetAcct.URI { // This is also weird... - err := errors.New("a Move is already stored for your account but contains invalid fields") - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + const text = "existing stored Move contains invalid fields" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } if originAcct.MovedToURI != move.TargetURI { // Huh... I'll be damned. - err := errors.New("stored Move target URI does not equal your moved_to_uri value") - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + const text = "existing stored Move target URI != moved_to_uri" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) } } else { // Move not stored yet, create it. @@ -295,7 +306,7 @@ func (p *Processor) MoveSelf( URI: moveURIStr, } if err := p.state.DB.PutMove(ctx, move); err != nil { - err := fmt.Errorf("db error storing move %s: %w", moveURIStr, err) + err := gtserror.Newf("db error storing move %s: %w", moveURIStr, err) return gtserror.NewErrorInternalError(err) } @@ -311,7 +322,7 @@ func (p *Processor) MoveSelf( "move_id", "moved_to_uri", ); err != nil { - err := fmt.Errorf("db error updating account: %w", err) + err := gtserror.Newf("db error updating account: %w", err) return gtserror.NewErrorInternalError(err) } } @@ -327,3 +338,55 @@ func (p *Processor) MoveSelf( return nil } + +// checkMoveRecursion checks that a move from origin to target would +// not cause a loop of account moved_from_uris pointing in a loop. +func (p *Processor) checkMoveRecursion( + ctx context.Context, + origin *gtsmodel.Account, + target *gtsmodel.Account, +) gtserror.WithCode { + // We only ever need barebones models. + ctx = gtscontext.SetBarebones(ctx) + + // Stack based account move following loop. + stack := []*gtsmodel.Account{origin} + checked := make(map[string]struct{}) + for len(stack) > 0 { + + // Pop account from stack. + next := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + // Add account URI to checked. + checked[next.URI] = struct{}{} + + // Fetch any accounts that list 'next' as their 'moved_to_uri'. + movedFrom, err := p.state.DB.GetAccountsByMovedToURI(ctx, next.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching accounts by moved_to_uri: %w", err) + return gtserror.NewErrorInternalError(err) + } + + for _, account := range movedFrom { + if _, ok := checked[account.URI]; ok { + // Account with URI has + // already been checked. + continue + } + + // Check movedFrom accounts to ensure + // none of them actually come from target, + // which would cause a recursion loop. + if account.URI == target.URI { + text := fmt.Sprintf("move %s -> %s would cause move recursion due to %s", origin.URI, target.URI, account.URI) + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Append 'from' account to stack. + stack = append(stack, account) + } + } + + return nil +} diff --git a/internal/processing/account/move_test.go b/internal/processing/account/move_test.go index c1a931252..9d06829ca 100644 --- a/internal/processing/account/move_test.go +++ b/internal/processing/account/move_test.go @@ -161,7 +161,7 @@ func (suite *MoveTestSuite) TestMoveAccountBadPassword() { MovedToURI: targetAcct.URI, }, ) - suite.EqualError(err, "invalid password provided in account Move request") + suite.EqualError(err, "invalid password provided in Move request") } func TestMoveTestSuite(t *testing.T) { diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index 1c1da4ca7..1b410bb0a 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -49,6 +49,7 @@ func (p *Processor) BoostCreate( return nil, errWithCode } + // Unwrap target in case it is a boost. target, errWithCode = p.c.UnwrapIfBoost( ctx, requester, @@ -58,7 +59,13 @@ func (p *Processor) BoostCreate( return nil, errWithCode } - // Ensure valid boost target. + // Check is viable target. + if target.BoostOfID != "" { + err := gtserror.Newf("target status %s is boost wrapper", target.URI) + return nil, gtserror.NewErrorUnprocessableEntity(err) + } + + // Ensure valid boost target for requester. boostable, err := p.filter.StatusBoostable(ctx, requester, target, diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 80f083ef1..a8f9b7f8f 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -147,6 +147,24 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. // In other words, this is the public record that the server has of an account. func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { + account, err := c.accountToAPIAccountPublic(ctx, a) + if err != nil { + return nil, err + } + + if a.MovedTo != nil { + account.Moved, err = c.accountToAPIAccountPublic(ctx, a.MovedTo) + if err != nil { + log.Errorf(ctx, "error converting account movedTo: %v", err) + } + } + + return account, nil +} + +// accountToAPIAccountPublic provides all the logic for AccountToAPIAccount, MINUS fetching moved account, to prevent possible recursion. +func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { + // Populate account struct fields. err := c.state.DB.PopulateAccount(ctx, a) @@ -154,7 +172,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A case err == nil: // No problem. - case err != nil && a.Stats != nil: + case a.Stats != nil: // We have stats so that's // *maybe* OK, try to continue. log.Errorf(ctx, "error(s) populating account, will continue: %s", err) @@ -266,37 +284,10 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A acct = a.Username // omit domain } - // Populate moved. - var moved *apimodel.Account - if a.MovedTo != nil { - moved, err = c.AccountToAPIAccountPublic(ctx, a.MovedTo) - if err != nil { - log.Errorf(ctx, "error converting account movedTo: %v", err) - } - } - - // Bool ptrs should be set, but warn - // and use a default if they're not. - var boolPtrDef = func( - pName string, - p *bool, - d bool, - ) bool { - if p != nil { - return *p - } - - log.Warnf(ctx, - "%s ptr was nil, using default %t", - pName, d, - ) - return d - } - var ( - locked = boolPtrDef("locked", a.Locked, true) - discoverable = boolPtrDef("discoverable", a.Discoverable, false) - bot = boolPtrDef("bot", a.Bot, false) + locked = util.PtrValueOr(a.Locked, true) + discoverable = util.PtrValueOr(a.Discoverable, false) + bot = util.PtrValueOr(a.Bot, false) ) // Remaining properties are simple and @@ -329,7 +320,6 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A EnableRSS: enableRSS, HideCollections: hideCollections, Role: role, - Moved: moved, } // Bodge default avatar + header in, @@ -350,7 +340,8 @@ func (c *Converter) fieldsToAPIFields(f []*gtsmodel.Field) []apimodel.Field { } if !field.VerifiedAt.IsZero() { - mField.VerifiedAt = func() *string { s := util.FormatISO8601(field.VerifiedAt); return &s }() + verified := util.FormatISO8601(field.VerifiedAt) + mField.VerifiedAt = util.Ptr(verified) } fields[i] = mField @@ -755,6 +746,10 @@ func (c *Converter) StatusToAPIStatus( var aside string aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments) apiStatus.Content += aside + if apiStatus.Reblog != nil { + aside, apiStatus.Reblog.MediaAttachments = placeholdUnknownAttachments(apiStatus.Reblog.MediaAttachments) + apiStatus.Reblog.Content += aside + } return apiStatus, nil } @@ -1050,28 +1045,82 @@ func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Sta // // Requesting account can be nil. func (c *Converter) statusToFrontend( + ctx context.Context, + status *gtsmodel.Status, + requestingAccount *gtsmodel.Account, + filterContext statusfilter.FilterContext, + filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, +) ( + *apimodel.Status, + error, +) { + apiStatus, err := c.baseStatusToFrontend(ctx, + status, + requestingAccount, + filterContext, + filters, + mutes, + ) + if err != nil { + return nil, err + } + + if status.BoostOf != nil { + reblog, err := c.baseStatusToFrontend(ctx, + status.BoostOf, + requestingAccount, + filterContext, + filters, + mutes, + ) + if errors.Is(err, statusfilter.ErrHideStatus) { + // If we'd hide the original status, hide the boost. + return nil, err + } else if err != nil { + return nil, gtserror.Newf("error converting boosted status: %w", err) + } + + // Set boosted status and set interactions from original. + apiStatus.Reblog = &apimodel.StatusReblogged{reblog} + apiStatus.Favourited = apiStatus.Reblog.Favourited + apiStatus.Bookmarked = apiStatus.Reblog.Bookmarked + apiStatus.Muted = apiStatus.Reblog.Muted + apiStatus.Reblogged = apiStatus.Reblog.Reblogged + apiStatus.Pinned = apiStatus.Reblog.Pinned + } + + return apiStatus, nil +} + +// baseStatusToFrontend performs the main logic +// of statusToFrontend() without handling of boost +// logic, to prevent *possible* recursion issues. +func (c *Converter) baseStatusToFrontend( ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, mutes *usermute.CompiledUserMuteList, -) (*apimodel.Status, error) { +) ( + *apimodel.Status, + error, +) { // Try to populate status struct pointer fields. // We can continue in many cases of partial failure, // but there are some fields we actually need. if err := c.state.DB.PopulateStatus(ctx, s); err != nil { - if s.Account == nil { - err = gtserror.Newf("error(s) populating status, cannot continue (status.Account not set): %w", err) - return nil, err - } + switch { + case s.Account == nil: + return nil, gtserror.Newf("error(s) populating status, required account not set: %w", err) - if s.BoostOfID != "" && s.BoostOf == nil { - err = gtserror.Newf("error(s) populating status, cannot continue (status.BoostOfID set, but status.Boost not set): %w", err) - return nil, err - } + case s.BoostOfID != "" && s.BoostOf == nil: + return nil, gtserror.Newf("error(s) populating status, required boost not set: %w", err) - log.Errorf(ctx, "error(s) populating status, will continue: %v", err) + default: + log.Errorf(ctx, "error(s) populating status, will continue: %v", err) + } } apiAuthorAccount, err := c.AccountToAPIAccountPublic(ctx, s.Account) @@ -1153,19 +1202,6 @@ func (c *Converter) statusToFrontend( apiStatus.Language = util.Ptr(s.Language) } - if s.BoostOf != nil { - reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters, mutes) - if errors.Is(err, statusfilter.ErrHideStatus) { - // If we'd hide the original status, hide the boost. - return nil, err - } - if err != nil { - return nil, gtserror.Newf("error converting boosted status: %w", err) - } - - apiStatus.Reblog = &apimodel.StatusReblogged{reblog} - } - if app := s.CreatedWithApplication; app != nil { apiStatus.Application, err = c.AppToAPIAppPublic(ctx, app) if err != nil { @@ -1190,14 +1226,9 @@ func (c *Converter) statusToFrontend( // Status interactions. // - // Take from boosted status if set, - // otherwise take from status itself. - if apiStatus.Reblog != nil { - apiStatus.Favourited = apiStatus.Reblog.Favourited - apiStatus.Bookmarked = apiStatus.Reblog.Bookmarked - apiStatus.Muted = apiStatus.Reblog.Muted - apiStatus.Reblogged = apiStatus.Reblog.Reblogged - apiStatus.Pinned = apiStatus.Reblog.Pinned + if s.BoostOf != nil { //nolint + // populated *outside* this + // function to prevent recursion. } else { interacts, err := c.interactionsWithStatusForAccount(ctx, s, requestingAccount) if err != nil { @@ -1230,6 +1261,7 @@ func (c *Converter) statusToFrontend( } return nil, fmt.Errorf("error applying filters: %w", err) } + apiStatus.Filtered = filterResults return apiStatus, nil