diff options
Diffstat (limited to 'internal/processing/account')
-rw-r--r-- | internal/processing/account/move.go | 241 | ||||
-rw-r--r-- | internal/processing/account/move_test.go | 175 |
2 files changed, 378 insertions, 38 deletions
diff --git a/internal/processing/account/move.go b/internal/processing/account/move.go index cd5c577c6..ca8dd4dea 100644 --- a/internal/processing/account/move.go +++ b/internal/processing/account/move.go @@ -23,14 +23,17 @@ import ( "fmt" "net/url" "slices" + "time" "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/uris" "golang.org/x/crypto/bcrypt" ) @@ -45,13 +48,14 @@ func (p *Processor) MoveSelf( return gtserror.NewErrorBadRequest(err, err.Error()) } - movedToURI, err := url.Parse(form.MovedToURI) + targetAcctURIStr := form.MovedToURI + targetAcctURI, err := url.Parse(form.MovedToURI) if err != nil { err := fmt.Errorf("invalid moved_to_uri provided in account Move request: %w", err) return gtserror.NewErrorBadRequest(err, err.Error()) } - if movedToURI.Scheme != "https" && movedToURI.Scheme != "http" { + 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()) } @@ -70,83 +74,244 @@ func (p *Processor) MoveSelf( return gtserror.NewErrorBadRequest(err, err.Error()) } + // 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( + "db error checking if target domain %s blocked: %w", + targetAcctURI.Host, err, + ) + return gtserror.NewErrorInternalError(err) + } + + if targetDomainBlocked { + err := fmt.Errorf( + "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()) + } + var ( // Current account from which // the move is taking place. - account = authed.Account + originAcct = authed.Account // Target account to which // the move is taking place. - targetAccount *gtsmodel.Account + targetAcct *gtsmodel.Account + + // AP representation of target. + targetAcctable ap.Accountable ) - switch { - case account.MovedToURI == "": - // No problemo. - - case account.MovedToURI == form.MovedToURI: - // Trying to move again to the same - // destination, perhaps to reprocess - // side effects. This is OK. - log.Info(ctx, - "reprocessing Move side effects from %s to %s", - account.URI, form.MovedToURI, - ) - - default: - // Account already moved, and now - // trying to move somewhere else. - err := fmt.Errorf( - "account %s is already Moved to %s, cannot also Move to %s", - account.URI, account.MovedToURI, form.MovedToURI, - ) - return gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } + // Next steps involve checking + setting + // state that might get messed up if a + // client triggers this function twice + // in quick succession, so get a lock on + // this account. + lockKey := originAcct.URI + unlock := p.state.ClientLocks.Lock(lockKey) + defer unlock() // Ensure we have a valid, up-to-date representation of the target account. - targetAccount, _, err = p.federator.GetAccountByURI(ctx, account.Username, movedToURI) + targetAcct, targetAcctable, err = p.federator.GetAccountByURI( + ctx, + originAcct.Username, + targetAcctURI, + ) if err != nil { err := fmt.Errorf("error dereferencing moved_to_uri account: %w", err) return gtserror.NewErrorUnprocessableEntity(err, err.Error()) } - if !targetAccount.SuspendedAt.IsZero() { + if !targetAcct.SuspendedAt.IsZero() { err := fmt.Errorf( "target account %s is suspended from this instance; "+ "you will not be able to Move to that account", - targetAccount.URI, + targetAcct.URI, ) return gtserror.NewErrorUnprocessableEntity(err, err.Error()) } + if targetAcct.IsRemote() { + // Force refresh Move target account + // to ensure we have up-to-date version. + targetAcct, _, err = p.federator.RefreshAccount(ctx, + originAcct.Username, + targetAcct, + targetAcctable, + dereferencing.Freshest, + ) + if err != nil { + err := fmt.Errorf( + "error refreshing target account %s: %w", + targetAcctURIStr, err, + ) + return gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + } + // Target account MUST be aliased to this // account for this to be a valid Move. - if !slices.Contains(targetAccount.AlsoKnownAsURIs, account.URI) { + if !slices.Contains(targetAcct.AlsoKnownAsURIs, originAcct.URI) { err := fmt.Errorf( "target account %s is not aliased to this account via alsoKnownAs; "+ - "if you just changed it, wait five minutes and try the Move again", - targetAccount.URI, + "if you just changed it, please wait a few minutes and try the Move again", + targetAcct.URI, ) return gtserror.NewErrorUnprocessableEntity(err, err.Error()) } // Target account cannot itself have // already Moved somewhere else. - if targetAccount.MovedToURI != "" { + if targetAcct.MovedToURI != "" { err := fmt.Errorf( "target account %s has already Moved somewhere else (%s); "+ "you will not be able to Move to that account", - targetAccount.URI, targetAccount.MovedToURI, + targetAcct.URI, targetAcct.MovedToURI, ) return gtserror.NewErrorUnprocessableEntity(err, err.Error()) } - // Everything seems OK, so process the Move. + // If a Move has been *attempted* within last 5m, + // that involved the origin and target in any way, + // then we shouldn't try to reprocess immediately. + latestMoveAttempt, err := p.state.DB.GetLatestMoveAttemptInvolvingURIs( + ctx, originAcct.URI, targetAcct.URI, + ) + if err != nil { + err := fmt.Errorf( + "error checking latest Move attempt involving origin %s and target %s: %w", + originAcct.URI, targetAcct.URI, err, + ) + return gtserror.NewErrorInternalError(err) + } + + if !latestMoveAttempt.IsZero() && + time.Since(latestMoveAttempt) < 5*time.Minute { + err := fmt.Errorf( + "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()) + } + + // If a Move has *succeeded* within the last week + // that involved the origin and target in any way, + // then we shouldn't process again for a while. + latestMoveSuccess, err := p.state.DB.GetLatestMoveSuccessInvolvingURIs( + ctx, originAcct.URI, targetAcct.URI, + ) + if err != nil { + err := fmt.Errorf( + "error checking latest Move success involving origin %s and target %s: %w", + originAcct.URI, targetAcct.URI, err, + ) + return gtserror.NewErrorInternalError(err) + } + + if !latestMoveSuccess.IsZero() && + time.Since(latestMoveSuccess) < 168*time.Hour { + err := fmt.Errorf( + "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()) + } + + // See if we have a Move stored already + // or if we need to create a new one. + var move *gtsmodel.Move + + if originAcct.MoveID != "" { + // Move already stored, ensure it's + // to the target and nothing weird is + // happening with race conditions etc. + move = originAcct.Move + if move == nil { + // This shouldn't happen... + err := fmt.Errorf("nil move for id %s", originAcct.MoveID) + 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()) + } + + 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()) + } + } else { + // Move not stored yet, create it. + moveID := id.NewULID() + moveURIStr := uris.GenerateURIForMove(originAcct.Username, moveID) + + // We might have selected the target + // using the URL and not the URI. + // Ensure we continue with the URI! + if targetAcctURIStr != targetAcct.URI { + targetAcctURIStr = targetAcct.URI + targetAcctURI, err = url.Parse(targetAcctURIStr) + if err != nil { + return gtserror.NewErrorInternalError(err) + } + } + + // Parse origin URI. + originAcctURI, err := url.Parse(originAcct.URI) + if err != nil { + return gtserror.NewErrorInternalError(err) + } + + // Store the Move. + move = >smodel.Move{ + ID: moveID, + AttemptedAt: time.Now(), + OriginURI: originAcct.URI, + Origin: originAcctURI, + TargetURI: targetAcctURIStr, + Target: targetAcctURI, + URI: moveURIStr, + } + if err := p.state.DB.PutMove(ctx, move); err != nil { + err := fmt.Errorf("db error storing move %s: %w", moveURIStr, err) + return gtserror.NewErrorInternalError(err) + } + + // Update account with the new + // Move, and set moved_to_uri. + originAcct.MoveID = move.ID + originAcct.Move = move + originAcct.MovedToURI = targetAcct.URI + originAcct.MovedTo = targetAcct + if err := p.state.DB.UpdateAccount( + ctx, + originAcct, + "move_id", + "moved_to_uri", + ); err != nil { + err := fmt.Errorf("db error updating account: %w", err) + return gtserror.NewErrorInternalError(err) + } + } + + // Everything seems OK, process Move side effects async. p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ APObjectType: ap.ActorPerson, APActivityType: ap.ActivityMove, - OriginAccount: account, - TargetAccount: targetAccount, + GTSModel: move, + OriginAccount: originAcct, + TargetAccount: targetAcct, }) return nil diff --git a/internal/processing/account/move_test.go b/internal/processing/account/move_test.go new file mode 100644 index 000000000..dfa0ea4e4 --- /dev/null +++ b/internal/processing/account/move_test.go @@ -0,0 +1,175 @@ +// 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 account_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +type MoveTestSuite struct { + AccountStandardTestSuite +} + +func (suite *MoveTestSuite) TestMoveAccountOK() { + ctx := context.Background() + + // Copy zork. + requestingAcct := new(gtsmodel.Account) + *requestingAcct = *suite.testAccounts["local_account_1"] + + // Copy admin. + targetAcct := new(gtsmodel.Account) + *targetAcct = *suite.testAccounts["admin_account"] + + // Update admin to alias back to zork. + targetAcct.AlsoKnownAsURIs = []string{requestingAcct.URI} + if err := suite.state.DB.UpdateAccount( + ctx, + targetAcct, + "also_known_as_uris", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Trigger move from zork to admin. + if err := suite.accountProcessor.MoveSelf( + ctx, + &oauth.Auth{ + Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: requestingAcct, + }, + &apimodel.AccountMoveRequest{ + Password: "password", + MovedToURI: targetAcct.URI, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // There should be a msg heading back to fromClientAPI. + select { + case msg := <-suite.fromClientAPIChan: + move, ok := msg.GTSModel.(*gtsmodel.Move) + if !ok { + suite.FailNow("", "could not cast %T to *gtsmodel.Move", move) + } + + now := time.Now() + suite.WithinDuration(now, move.CreatedAt, 5*time.Second) + suite.WithinDuration(now, move.UpdatedAt, 5*time.Second) + suite.WithinDuration(now, move.AttemptedAt, 5*time.Second) + suite.Zero(move.SucceededAt) + suite.NotZero(move.ID) + suite.Equal(requestingAcct.URI, move.OriginURI) + suite.NotNil(move.Origin) + suite.Equal(targetAcct.URI, move.TargetURI) + suite.NotNil(move.Target) + suite.NotZero(move.URI) + + case <-time.After(5 * time.Second): + suite.FailNow("time out waiting for message") + } + + // Move should be in the database now. + move, err := suite.state.DB.GetMoveByOriginTarget( + ctx, + requestingAcct.URI, + targetAcct.URI, + ) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(move) + + // Origin account should have move ID and move to URI set. + suite.Equal(move.ID, requestingAcct.MoveID) + suite.Equal(targetAcct.URI, requestingAcct.MovedToURI) +} + +func (suite *MoveTestSuite) TestMoveAccountNotAliased() { + ctx := context.Background() + + // Copy zork. + requestingAcct := new(gtsmodel.Account) + *requestingAcct = *suite.testAccounts["local_account_1"] + + // Don't copy admin. + targetAcct := suite.testAccounts["admin_account"] + + // Trigger move from zork to admin. + // + // Move should fail since admin is + // not aliased back to zork. + err := suite.accountProcessor.MoveSelf( + ctx, + &oauth.Auth{ + Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: requestingAcct, + }, + &apimodel.AccountMoveRequest{ + Password: "password", + MovedToURI: targetAcct.URI, + }, + ) + suite.EqualError(err, "target account http://localhost:8080/users/admin is not aliased to this account via alsoKnownAs; if you just changed it, please wait a few minutes and try the Move again") +} + +func (suite *MoveTestSuite) TestMoveAccountBadPassword() { + ctx := context.Background() + + // Copy zork. + requestingAcct := new(gtsmodel.Account) + *requestingAcct = *suite.testAccounts["local_account_1"] + + // Don't copy admin. + targetAcct := suite.testAccounts["admin_account"] + + // Trigger move from zork to admin. + // + // Move should fail since admin is + // not aliased back to zork. + err := suite.accountProcessor.MoveSelf( + ctx, + &oauth.Auth{ + Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: requestingAcct, + }, + &apimodel.AccountMoveRequest{ + Password: "boobies", + MovedToURI: targetAcct.URI, + }, + ) + suite.EqualError(err, "invalid password provided in account Move request") +} + +func TestMoveTestSuite(t *testing.T) { + suite.Run(t, new(MoveTestSuite)) +} |